From f0339dc6fc6a06b7c5a004f439cb9cfc23a7a6d3 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:01:00 +0000 Subject: [PATCH 1/9] ci: add govulncheck step to Go test job Runs govulncheck ./... on every PR touching Go code. Reachability-based scan catches High/Critical CVEs in the call graph before tests run; aligns CI with ~/.claude/rules/security.md policy. Installs the tool on-the-fly since it's a first-party golang.org/x module and dependabot can bump the install target as needed. Local dogfood: "No vulnerabilities found". Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c3c72c..76775ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,14 @@ jobs: - name: go vet (cgo + fts5) run: CGO_ENABLED=1 go vet -tags sqlite_fts5 $(go list ./... | grep -v /ui/node_modules/) + - name: govulncheck + run: | + set -eu + # govulncheck is a first-party golang.org/x module; @latest is + # acceptable here and dependabot can bump the install target. + go install golang.org/x/vuln/cmd/govulncheck@latest + CGO_ENABLED=1 govulncheck -tags sqlite_fts5 ./... + - name: go test (cgo + fts5) run: CGO_ENABLED=1 go test -tags sqlite_fts5 -timeout 300s $(go list ./... | grep -v /ui/node_modules/) From 523ef59b37381f593873ca8e998cadbfed8702ac Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:01:17 +0000 Subject: [PATCH 2/9] ci(ui): fail build on moderate+ npm advisories Adds npm audit --audit-level=moderate between npm ci and typecheck in the UI job. Short-circuits the rest of the job on a failing audit so CVE-introducing dep bumps surface immediately. Matches the rule-book policy in ~/.claude/rules/security.md. Local dogfood: "found 0 vulnerabilities". Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76775ba..bff689d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Install UI dependencies run: npm --prefix ui ci + - name: npm audit + run: npm --prefix ui audit --audit-level=moderate + - name: Type check run: npm --prefix ui run typecheck From 54fa6b0c6260092b1976dc7e2b36bd7e97e6a4d9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:04:04 +0000 Subject: [PATCH 3/9] test: add FuzzSearchTokenize and FuzzMCPToolArgs to fuzz-smoke Two new fuzz targets wired into the existing fuzz (smoke) CI job: - FuzzSearchTokenize exercises Store.SearchNotes against arbitrary FTS5-grammar inputs; asserts no "malformed MATCH expression" leaks, which would indicate missing pre-sanitisation at the HTTP boundary. - FuzzMCPToolArgs exercises stringArg/intArg/projectArg against any JSON payload an MCP client might send; asserts no helper panics on unexpected types. Each runs 30s on every PR. Local 15s smoke: both targets PASS with new-interesting counts of 31 and 171 respectively; ~84k and ~554k execs in 15s. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fuzz.yml | 2 + internal/mcp/tools_fuzz_test.go | 76 ++++++++++++++++++++++++++++++ internal/store/notes_fuzz_test.go | 78 +++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 internal/mcp/tools_fuzz_test.go create mode 100644 internal/store/notes_fuzz_test.go diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 53ceb33..c1d6f82 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -27,6 +27,8 @@ jobs: targets=( "./internal/crawler::FuzzResolveURL" "./internal/chunker::FuzzChunker" + "./internal/store::FuzzSearchTokenize" + "./internal/mcp::FuzzMCPToolArgs" ) for entry in "${targets[@]}"; do pkg="${entry%%::*}" diff --git a/internal/mcp/tools_fuzz_test.go b/internal/mcp/tools_fuzz_test.go new file mode 100644 index 0000000..cf2e7d4 --- /dev/null +++ b/internal/mcp/tools_fuzz_test.go @@ -0,0 +1,76 @@ +//go:build sqlite_fts5 + +package mcp + +import ( + "encoding/json" + "strings" + "testing" +) + +// FuzzMCPToolArgs asserts that the argument-coercion helpers (stringArg, +// intArg, and the `project` shortcut projectArg) never panic on any JSON +// payload an MCP client might send. We fuzz a JSON blob, unmarshal it +// into map[string]any (the exact type the real handlers receive via +// mcpgo.CallToolRequest.GetArguments()), and poke each helper with the +// known keys plus a couple of keys that intentionally don't exist. +func FuzzMCPToolArgs(f *testing.F) { + // Seeds cover the shapes that flow through the real tool registrations + // in tools.go: strings, numbers (float64 after JSON round-trip), + // booleans, nulls, nested objects, and arrays. Malformed JSON is fed + // via the "ignore" branch — the unmarshal error is expected and + // skipped so it does not count as a fuzzer-discovered crash. + seeds := []string{ + `{}`, + `{"query":"hello"}`, + `{"query":""}`, + `{"top_k":5}`, + `{"top_k":5.5}`, + `{"top_k":-1}`, + `{"top_k":"not a number"}`, + `{"project":null}`, + `{"project":true}`, + `{"project":["nested","array"]}`, + `{"project":{"nested":"object"}}`, + `{"entity_name":"foo","depth":2}`, + `{"community_level":0}`, + `{"` + strings.Repeat("a", 1024) + `":"long-key"}`, + `not json at all`, + ``, + } + for _, s := range seeds { + f.Add(s) + } + + // All known argument keys used across internal/mcp/tools.go and + // notes_tools.go. Exhaustive is cheap; if a new tool adds a new + // key this list lags but the fuzz target still covers the helpers. + keys := []string{ + "query", "top_k", "doc_type", "project", + "community_level", "entity_name", "depth", + "from", "to", "predicate", + "note_key", "content", "tags", "limit", + "max_nodes", "graph_depth", "doc_id", "type", + "nonexistent_key_for_default_path", + } + + f.Fuzz(func(t *testing.T, raw string) { + var args map[string]any + if err := json.Unmarshal([]byte(raw), &args); err != nil { + // Not valid JSON — not our target. MCP transport layer + // already rejects these before they reach tool handlers. + t.Skip() + } + if args == nil { + // JSON "null" at the top level — nothing to coerce. + return + } + + for _, k := range keys { + _ = stringArg(args, k, "default") + _ = intArg(args, k, 0) + } + // projectArg lives in server.go and wraps stringArg for "project". + _ = projectArg(args) + }) +} diff --git a/internal/store/notes_fuzz_test.go b/internal/store/notes_fuzz_test.go new file mode 100644 index 0000000..595bb0c --- /dev/null +++ b/internal/store/notes_fuzz_test.go @@ -0,0 +1,78 @@ +//go:build sqlite_fts5 + +package store + +import ( + "context" + "strconv" + "strings" + "testing" + + "github.com/RandomCodeSpace/docsiq/internal/notes" +) + +// FuzzSearchTokenize asserts that Store.SearchNotes never panics and never +// returns a "malformed MATCH expression" error for any query string. Any +// input that trips the latter must be fixed by pre-sanitising inside +// SearchNotes — the HTTP boundary cannot be trusted to pre-filter FTS5 +// control characters, and clients routinely pass the raw search box. +func FuzzSearchTokenize(f *testing.F) { + // Seeds cover the FTS5 grammar corners that historically broke: + // empty / whitespace, unbalanced quotes / parens, bare operators, + // column-qualified terms, prefix wildcards, NULL bytes, Unicode, RTL. + seeds := []string{ + "", + " ", + "hello world", + `"unbalanced`, + "(lonely", + "AND", + "NOT title:foo", + "foo*", + "\x00\x00", + strings.Repeat("a", 4096), + "你好 世界", + "مرحبا", // RTL + "a OR (b AND \"c d\")", + "col:tag1 tag2", + "--comment", + "/* comment */", + "foo bar -", + ";", + } + for _, s := range seeds { + f.Add(s) + } + + // One shared store per fuzz process. Re-opening per iteration would + // dominate runtime and produce no new coverage — the surface under + // test is the query path, not Open. + dir := f.TempDir() + s, err := OpenForProject(dir, "fuzzproj") + if err != nil { + f.Fatalf("OpenForProject: %v", err) + } + f.Cleanup(func() { _ = s.Close() }) + ctx := context.Background() + + // Seed a handful of notes so MATCH has something non-empty to scan. + // An empty FTS5 index short-circuits most of the query planner and + // would hide grammar bugs. + for i, body := range []string{"alpha beta", "gamma delta", "title something"} { + key := "k" + strconv.Itoa(i) + if err := s.IndexNote(ctx, ¬es.Note{Key: key, Content: body, Tags: []string{"tag"}}); err != nil { + f.Fatalf("seed IndexNote: %v", err) + } + } + + f.Fuzz(func(t *testing.T, query string) { + // We don't care about the result — only that the call completes + // without panic and without leaking a raw FTS5 syntax error. + _, err := s.SearchNotes(ctx, query, 5) + if err != nil && strings.Contains(err.Error(), "malformed MATCH expression") { + t.Fatalf("unsanitised FTS5 grammar leaked for query %q: %v", query, err) + } + // Other errors (e.g. context cancelled) are acceptable during + // fuzzing; they are not the class of bug this target is hunting. + }) +} From b698ae3e859fdc2718c56519a679dd9b6a4305c9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:05:46 +0000 Subject: [PATCH 4/9] =?UTF-8?q?test,ci:=20enforce=20flake-register=20?= =?UTF-8?q?=E2=80=94=20every=20skip=20gets=20a=20tracked=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotates the 8 existing t.Skip() sites with TODO(#N) references and adds a CI grep gate (in the test job) that fails if any future t.Skip or test.skip lacks an adjacent TODO(#N): comment. Issues filed: - #62 large-tar import test under -short - #63 1000-note scale test under -short - #64 10k HNSW benchmarks (-short, -race) - #65 environmental skips (platform/tool availability) Converts silent skips into a queryable backlog without changing test behaviour. Fuzz-callback skips (*_fuzz_test.go) are excluded: those are input filtering, not flake-register entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++ internal/api/notes_import_limits_test.go | 1 + internal/hookinstaller/installer_test.go | 1 + internal/notes/history_test.go | 1 + internal/notes/notes_test.go | 1 + internal/project/registry_test.go | 2 ++ internal/vectorindex/hnsw_test.go | 2 ++ 7 files changed, 48 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bff689d..5d795b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,46 @@ jobs: - name: go build (cgo + fts5) run: CGO_ENABLED=1 go build -tags sqlite_fts5 -o docsiq ./ + - name: flake-register (every t.Skip / test.skip has a tracked TODO) + run: | + set -euo pipefail + # Every skip must be either: + # (a) on a line with an inline `// TODO(#N):` comment, OR + # (b) immediately preceded by a `// TODO(#N):` comment line. + # Fuzz-callback skips (input filtering) are excluded: they are + # not flake-register entries and carry no issue. + echo "Scanning for t.Skip( without a tracked TODO..." + violations=0 + # Go side + while IFS=: read -r file lineno _; do + if sed -n "${lineno}p" "$file" | grep -qE '// TODO\(#[0-9]+\):'; then + continue + fi + prev=$((lineno - 1)) + if [ "$prev" -gt 0 ] && sed -n "${prev}p" "$file" | grep -qE '// TODO\(#[0-9]+\):'; then + continue + fi + echo "::error file=$file,line=$lineno::t.Skip without TODO(#N): annotation" + violations=$((violations + 1)) + done < <(grep -rn 't\.Skip(' --include='*.go' . | grep -v '_fuzz_test\.go' | grep -v node_modules || true) + # TypeScript side + while IFS=: read -r file lineno _; do + if sed -n "${lineno}p" "$file" | grep -qE '// TODO\(#[0-9]+\):'; then + continue + fi + prev=$((lineno - 1)) + if [ "$prev" -gt 0 ] && sed -n "${prev}p" "$file" | grep -qE '// TODO\(#[0-9]+\):'; then + continue + fi + echo "::error file=$file,line=$lineno::test.skip without TODO(#N): annotation" + violations=$((violations + 1)) + done < <(grep -rn 'test\.skip(' --include='*.ts' --include='*.tsx' ui/ 2>/dev/null | grep -v node_modules || true) + if [ "$violations" -gt 0 ]; then + echo "::error::Found $violations skipped test(s) without a tracking issue. File a flake-register issue and add // TODO(#N): adjacent to the skip." + exit 1 + fi + echo "All skips accounted for." + - name: Upload docsiq binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/internal/api/notes_import_limits_test.go b/internal/api/notes_import_limits_test.go index bbbd682..29a9461 100644 --- a/internal/api/notes_import_limits_test.go +++ b/internal/api/notes_import_limits_test.go @@ -78,6 +78,7 @@ func TestImportTar_EntryCountCap(t *testing.T) { // MaxImportTotalBytes must be rejected with 413. func TestImportTar_TotalBytesCap(t *testing.T) { if testing.Short() { + // TODO(#62): large-tar import test skipped under -short; tracked in flake-register. t.Skip("skipping large-tar test in -short mode") } h, slug, _ := setupNotesRouter(t) diff --git a/internal/hookinstaller/installer_test.go b/internal/hookinstaller/installer_test.go index 9bb2db7..b2910f0 100644 --- a/internal/hookinstaller/installer_test.go +++ b/internal/hookinstaller/installer_test.go @@ -262,6 +262,7 @@ func TestClaudeInstaller(t *testing.T) { t.Run("symlinked_config_is_written_through", func(t *testing.T) { if runtime.GOOS == "windows" { + // TODO(#65): environmental skip (windows symlink admin); tracked in flake-register. t.Skip("symlink support requires admin on Windows") } home := fakeHome(t) diff --git a/internal/notes/history_test.go b/internal/notes/history_test.go index e5a590d..9998830 100644 --- a/internal/notes/history_test.go +++ b/internal/notes/history_test.go @@ -13,6 +13,7 @@ import ( func skipIfNoGit(t *testing.T) { t.Helper() if _, err := exec.LookPath("git"); err != nil { + // TODO(#65): environmental skip (git binary missing); tracked in flake-register. t.Skip("git not available") } } diff --git a/internal/notes/notes_test.go b/internal/notes/notes_test.go index 79214d1..138739a 100644 --- a/internal/notes/notes_test.go +++ b/internal/notes/notes_test.go @@ -227,6 +227,7 @@ func TestUnicodeKey(t *testing.T) { func TestScale_1000Notes(t *testing.T) { if testing.Short() { + // TODO(#63): 1000-note scale test skipped under -short; tracked in flake-register. t.Skip("skipping 1000-note scale test in -short mode") } dir := t.TempDir() diff --git a/internal/project/registry_test.go b/internal/project/registry_test.go index c4ad6ce..092958c 100644 --- a/internal/project/registry_test.go +++ b/internal/project/registry_test.go @@ -44,9 +44,11 @@ func TestOpenRegistry_CreatesDir(t *testing.T) { func TestOpenRegistry_ReadOnlyDir(t *testing.T) { if runtime.GOOS == "windows" { + // TODO(#65): environmental skip (windows chmod semantics); tracked in flake-register. t.Skip("chmod semantics differ on windows") } if os.Getuid() == 0 { + // TODO(#65): environmental skip (root bypasses chmod 0555); tracked in flake-register. t.Skip("running as root; chmod 0555 does not block writes") } parent := t.TempDir() diff --git a/internal/vectorindex/hnsw_test.go b/internal/vectorindex/hnsw_test.go index 2d8d727..1f22a6e 100644 --- a/internal/vectorindex/hnsw_test.go +++ b/internal/vectorindex/hnsw_test.go @@ -213,6 +213,7 @@ func TestHNSW_Upsert(t *testing.T) { func TestHNSW_Recall10k(t *testing.T) { if testing.Short() { + // TODO(#64): 10k HNSW benchmark skipped under -short; tracked in flake-register. t.Skip("skipping 10k benchmark in -short") } if raceEnabled { @@ -220,6 +221,7 @@ func TestHNSW_Recall10k(t *testing.T) { // detector has nothing to catch here — it just adds ~10× overhead // that dominates CI. Concurrency correctness is covered by // TestHNSW_ConcurrentAddSearch, which DOES run under -race. + // TODO(#64): 10k HNSW recall benchmark skipped under -race; tracked in flake-register. t.Skip("skipping 10k recall benchmark under -race (sequential workload)") } const ( From 165e5d74cf517ecc222777b4f56be10b4440c644 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:07:49 +0000 Subject: [PATCH 5/9] test(ui): add 404, unauthed, and upload-happy-path Playwright smokes Three high-risk flows get dedicated specs: - 404.spec.ts (2 tests): unknown and deep-nested unknown routes render the NotFound component while keeping the Shell mounted. Guards against react-router catch-all regressions. - auth.spec.ts (2 tests, .fixme): asserts a visible auth-required affordance when /api/* returns 401. Today the UI has no such affordance (apiFetch throws into React Query error states with no recognisable copy) so the tests are .fixme'd with TODO(#66): tracked in flake-register. - upload.spec.ts (1 test): opens DocumentsList > Upload, attaches a fixture markdown to the , stubs POST /api/upload, asserts the dialog closes on success. Mirrors the real UploadModal flow (onChange auto-submit, no explicit submit button). Local run: 10 specs, 8 pass + 2 fixme. Playwright job grows from 5 to 10; regressions in any of the three flows now fail CI in ~10s. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/e2e/404.spec.ts | 26 +++++++++++ ui/e2e/auth.spec.ts | 67 ++++++++++++++++++++++++++++ ui/e2e/fixtures/fixture.md | 5 +++ ui/e2e/upload.spec.ts | 90 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 ui/e2e/404.spec.ts create mode 100644 ui/e2e/auth.spec.ts create mode 100644 ui/e2e/fixtures/fixture.md create mode 100644 ui/e2e/upload.spec.ts diff --git a/ui/e2e/404.spec.ts b/ui/e2e/404.spec.ts new file mode 100644 index 0000000..af1a2c2 --- /dev/null +++ b/ui/e2e/404.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "./fixtures"; + +test.describe("404", () => { + test("unknown route renders NotFound and keeps the shell", async ({ stubbedPage: page }) => { + await page.goto("/this-route-does-not-exist"); + + // NotFound lives inside the Shell, so the main landmark still renders. + await expect(page.locator("main#main")).toBeVisible(); + + // NotFound component copy is stable; see ui/src/App.tsx NotFound(). + await expect( + page.getByRole("heading", { name: /^not found$/i }), + ).toBeVisible(); + await expect(page.getByText(/no such page/i)).toBeVisible(); + }); + + test("nested unknown route also renders NotFound (shell intact)", async ({ stubbedPage: page }) => { + await page.goto("/definitely/not/a/real/path"); + // The catch-all in App.tsx handles any unmatched depth — confirm it + // still fires for deep URLs (react-router ordering regression guard). + await expect(page.locator("main#main")).toBeVisible(); + await expect( + page.getByRole("heading", { name: /^not found$/i }), + ).toBeVisible(); + }); +}); diff --git a/ui/e2e/auth.spec.ts b/ui/e2e/auth.spec.ts new file mode 100644 index 0000000..84c4842 --- /dev/null +++ b/ui/e2e/auth.spec.ts @@ -0,0 +1,67 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +// This spec overrides the default stubbedPage fixture because we want +// the API to return 401, not 200-with-empty-body. Kept self-contained +// so no export from fixtures.ts is required. +const API_PATH = /^\/api\//; +const MCP_PATH = /^\/mcp\//; + +async function stubUnauthed(page: Page) { + await page.route( + (url) => API_PATH.test(url.pathname), + (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "unauthenticated" }), + }), + ); + await page.route( + (url) => MCP_PATH.test(url.pathname), + (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "unauthenticated" }), + }), + ); +} + +const test = base.extend<{ unauthedPage: Page }>({ + unauthedPage: async ({ page }, use) => { + await stubUnauthed(page); + await use(page); + }, +}); + +// TODO(#66): re-enable these once the UI renders a visible "sign in" / +// "authentication required" affordance on 401. Today apiFetch throws an +// ApiErrorResponse that bubbles into React Query error states without a +// recognisable auth-copy surface. See flake-register issue #66. +test.describe("unauthed API", () => { + test.fixme( + "home surfaces an auth-required affordance when /api/* returns 401", + async ({ unauthedPage: page }) => { + await page.goto("/"); + await expect(page.locator("main#main")).toBeVisible(); + await expect( + page + .getByText(/sign in|authenticat|authori|session expired|please log in/i) + .first(), + ).toBeVisible({ timeout: 5_000 }); + }, + ); + + test.fixme( + "navigating to /notes with 401 shows the same affordance", + async ({ unauthedPage: page }) => { + await page.goto("/notes"); + await expect(page.locator("main#main")).toBeVisible(); + await expect( + page + .getByText(/sign in|authenticat|authori|session expired|please log in/i) + .first(), + ).toBeVisible({ timeout: 5_000 }); + }, + ); +}); diff --git a/ui/e2e/fixtures/fixture.md b/ui/e2e/fixtures/fixture.md new file mode 100644 index 0000000..22312ad --- /dev/null +++ b/ui/e2e/fixtures/fixture.md @@ -0,0 +1,5 @@ +# Test Fixture + +This is a tiny markdown file used by the upload happy-path smoke. +It exists only so the file-chooser interception has a real file +to hand to the UI. diff --git a/ui/e2e/upload.spec.ts b/ui/e2e/upload.spec.ts new file mode 100644 index 0000000..518e619 --- /dev/null +++ b/ui/e2e/upload.spec.ts @@ -0,0 +1,90 @@ +import { test as base, expect, type Page } from "@playwright/test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Upload flow stubs: the real UI POSTs FormData to /api/upload and +// closes the modal on success. See ui/src/routes/documents/UploadModal.tsx. +const API_PATH = /^\/api\//; +const MCP_PATH = /^\/mcp\//; + +async function stubUploadAccept(page: Page) { + await page.route( + (url) => API_PATH.test(url.pathname), + async (route) => { + const req = route.request(); + const p = new URL(req.url()).pathname; + + // Upload accept — return a synthetic success. The real endpoint + // is POST /api/upload?project=...; see UploadModal.tsx:20. + if (req.method() === "POST" && /\/upload$/.test(p)) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, count: 1 }), + }); + } + // Post-upload list refresh returns the uploaded document so the + // documents grid reflects the write. + if (/\/documents(\?|$)/.test(p)) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: "doc-1", title: "fixture.md", doc_type: "md" }, + ]), + }); + } + if (/\/stats$/.test(p)) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ notes: 0, documents: 1, entities: 0, relationships: 0 }), + }); + } + if (/\/notes(\?|$)/.test(p) || /\/activity(\?|$)/.test(p)) { + return route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + } + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }, + ); + await page.route( + (url) => MCP_PATH.test(url.pathname), + (route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ); +} + +const test = base.extend<{ uploadPage: Page }>({ + uploadPage: async ({ page }, use) => { + await stubUploadAccept(page); + await use(page); + }, +}); + +test.describe("upload happy-path", () => { + test("user can open the upload modal, pick a file, and see the modal close on success", async ({ uploadPage: page }) => { + await page.goto("/docs"); + await expect(page.locator("main#main")).toBeVisible(); + + // Open the upload affordance — UploadModal.tsx has . + const uploadButton = page.getByRole("button", { name: /^upload$/i }).first(); + await expect(uploadButton).toBeVisible({ timeout: 5_000 }); + await uploadButton.click(); + + // DialogTitle is "Upload documents" — verify the modal actually opened. + await expect(page.getByRole("heading", { name: /upload documents/i })).toBeVisible(); + + // The modal renders a bare (no button). Set files + // directly on the input; onChange fires onFiles which POSTs and then + // calls onOpenChange(false), closing the dialog. + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(path.join(__dirname, "fixtures/fixture.md")); + + // Success surface: the modal closes when the POST resolves, so the + // DialogTitle should disappear within a few seconds. + await expect( + page.getByRole("heading", { name: /upload documents/i }), + ).toBeHidden({ timeout: 5_000 }); + }); +}); From dc275262e5aa1ba7503af1751cd579ffcf5cb589 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:09:57 +0000 Subject: [PATCH 6/9] feat(llm): deterministic mock provider for tests internal/llm/mock implements llm.Provider with: - Complete: substring-matched canned JSON for entity/relationship/claim extraction prompts and a TITLE:/SUMMARY: formatted response for community summarisation prompts. Schema matches internal/extractor and internal/community exactly so parsing succeeds. - Embed/EmbedBatch: SHA-256-derived L2-normalised vectors, 128-dim by default. Equal text yields equal vectors; determinism is the only semantic contract. Intended for integration tests; not exposed outside internal/. No network, no API keys, no external processes. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/llm/mock/mock.go | 164 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/llm/mock/mock.go diff --git a/internal/llm/mock/mock.go b/internal/llm/mock/mock.go new file mode 100644 index 0000000..a4b7833 --- /dev/null +++ b/internal/llm/mock/mock.go @@ -0,0 +1,164 @@ +// Package mock provides a deterministic llm.Provider implementation for +// tests. It does NOT require any network, API key, or external process. +// Callers import it directly (no build tag) — the package lives under +// internal/ so it cannot leak into the public API surface. +package mock + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "math" + "strings" + + "github.com/RandomCodeSpace/docsiq/internal/llm" +) + +// DefaultDims is the default embedding dimensionality. +const DefaultDims = 128 + +// Provider is a deterministic, in-memory llm.Provider useful for unit +// and integration tests. It inspects the prompt for known substrings +// and returns canned, schema-valid JSON; embeddings are derived from a +// SHA-256 of the input so equal text yields equal vectors. +type Provider struct { + Dims int +} + +// Compile-time check that *Provider satisfies llm.Provider. +var _ llm.Provider = (*Provider)(nil) + +// New returns a mock provider. Pass 0 for DefaultDims (128). +func New(dims int) *Provider { + if dims <= 0 { + dims = DefaultDims + } + return &Provider{Dims: dims} +} + +func (p *Provider) Name() string { return "mock" } +func (p *Provider) ModelID() string { return "mock-llm" } + +// Complete returns a deterministic response chosen by prompt substring. +// Schema must match what internal/extractor and internal/community +// expect; see entityPrompt in internal/extractor/entities.go and +// communityPrompt in internal/community/summarizer.go. +func (p *Provider) Complete(ctx context.Context, prompt string, _ ...llm.Option) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + lower := strings.ToLower(prompt) + + switch { + case strings.Contains(lower, "knowledge graph") && strings.Contains(lower, "entities"): + // Entity + relationship extraction. The pipeline parses this + // JSON via internal/extractor — schema must match exactly. + // Stable entity names derived from prompt-hash so different + // chunks yield different graphs; dedup then collapses across + // the corpus. + tag := hashTag(prompt, 2) + return fmt.Sprintf(`{ + "entities": [ + {"name": "Entity_%s_A", "type": "Concept", "description": "deterministic mock entity A"}, + {"name": "Entity_%s_B", "type": "Concept", "description": "deterministic mock entity B"} + ], + "relationships": [ + {"source": "Entity_%s_A", "target": "Entity_%s_B", "predicate": "relates_to", "description": "mock edge", "weight": 1.0} + ] +}`, tag, tag, tag, tag), nil + + case strings.Contains(lower, "claim"): + tag := hashTag(prompt, 2) + return fmt.Sprintf(`{ + "claims": [ + {"subject": "Entity_%s_A", "predicate": "is", "object": "mock claim", "description": "deterministic"} + ] +}`, tag), nil + + case strings.Contains(lower, "community") || strings.Contains(lower, "summar"): + // Must match parseCommunityReport which looks for "TITLE:" and "SUMMARY:" prefixes. + return "TITLE: Mock community\nSUMMARY: A deterministic, test-only paragraph describing the community of entities in scope.", nil + + default: + // Unknown prompt — return empty JSON so whatever caller gets + // it can proceed without a parse error. + return `{}`, nil + } +} + +// Embed returns a Dims-length vector derived from SHA-256(text). Equal +// text yields equal vectors. +func (p *Provider) Embed(ctx context.Context, text string) ([]float32, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return hashEmbedding(text, p.Dims), nil +} + +func (p *Provider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { + out := make([][]float32, len(texts)) + for i, t := range texts { + v, err := p.Embed(ctx, t) + if err != nil { + return nil, err + } + out[i] = v + } + return out, nil +} + +// hashEmbedding derives a stable dims-length unit vector from SHA-256(text). +// Runs SHA-256 repeatedly with a counter suffix until dims float32s have +// been produced, then L2-normalises. O(dims) time, zero allocations in +// the hot path beyond the output slice. +func hashEmbedding(text string, dims int) []float32 { + if dims <= 0 { + dims = DefaultDims + } + out := make([]float32, dims) + seed := []byte(text) + var i int + for counter := uint32(0); i < dims; counter++ { + var ctrBuf [4]byte + binary.LittleEndian.PutUint32(ctrBuf[:], counter) + h := sha256.New() + h.Write(seed) + h.Write(ctrBuf[:]) + sum := h.Sum(nil) + // Each sha256 gives 32 bytes → 8 float32s via uint32 LE. + for j := 0; j < len(sum) && i < dims; j += 4 { + u := binary.LittleEndian.Uint32(sum[j : j+4]) + // Map uint32 into (-1, 1). + out[i] = float32(int32(u))/float32(math.MaxInt32) - 0 + i++ + } + } + // L2-normalise so cosine similarity stays well defined. + var norm float64 + for _, v := range out { + norm += float64(v) * float64(v) + } + if norm == 0 { + out[0] = 1 + return out + } + inv := float32(1.0 / math.Sqrt(norm)) + for k := range out { + out[k] *= inv + } + return out +} + +// hashTag returns the first n hex chars of SHA-256(s) — used as a +// stable, short identifier in canned entity names. +func hashTag(s string, n int) string { + sum := sha256.Sum256([]byte(s)) + const hex = "0123456789abcdef" + out := make([]byte, n*2) + for i := 0; i < n; i++ { + out[2*i] = hex[sum[i]>>4] + out[2*i+1] = hex[sum[i]&0x0f] + } + return string(out) +} From 48acfaabe8458aecdf6a974c06d089817a86e106 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:11:24 +0000 Subject: [PATCH 7/9] test(pipeline): end-to-end integration test over markdown corpus New integration test drives pipeline.New().IndexPath().Finalize() over 5 small markdown files with the mock LLM provider, then asserts: - Document count is exactly 5. - Chunk count is in the 5..50 band. - Embedding count equals chunk count (Phase 2 invariant). - Entity count is in the 2..2*chunks band (mock returns 2 entities per extraction prompt; dedup collapses duplicates). - Relationship count is >=1. - LocalSearch("Apollo program", topK=5) returns >=1 chunk containing "Apollo". Gated by //go:build integration && sqlite_fts5 so the default `go test ./...` path is unaffected. The test-integration CI job picks it up automatically via its existing -tags "sqlite_fts5 integration" invocation. No CI workflow change needed. Local run: 0.05s without -race, 1.07s with -race. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/pipeline/integration_test.go | 156 ++++++++++++++++++++++++++ testdata/pipeline/alpha.md | 10 ++ testdata/pipeline/beta.md | 7 ++ testdata/pipeline/delta.md | 5 + testdata/pipeline/epsilon.md | 5 + testdata/pipeline/gamma.md | 11 ++ 6 files changed, 194 insertions(+) create mode 100644 internal/pipeline/integration_test.go create mode 100644 testdata/pipeline/alpha.md create mode 100644 testdata/pipeline/beta.md create mode 100644 testdata/pipeline/delta.md create mode 100644 testdata/pipeline/epsilon.md create mode 100644 testdata/pipeline/gamma.md diff --git a/internal/pipeline/integration_test.go b/internal/pipeline/integration_test.go new file mode 100644 index 0000000..33b40b3 --- /dev/null +++ b/internal/pipeline/integration_test.go @@ -0,0 +1,156 @@ +//go:build integration && sqlite_fts5 + +package pipeline_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/docsiq/internal/config" + "github.com/RandomCodeSpace/docsiq/internal/embedder" + "github.com/RandomCodeSpace/docsiq/internal/llm/mock" + "github.com/RandomCodeSpace/docsiq/internal/pipeline" + "github.com/RandomCodeSpace/docsiq/internal/search" + "github.com/RandomCodeSpace/docsiq/internal/store" +) + +// TestPipeline_IndexAndSearch_EndToEnd drives pipeline.New().IndexPath() +// followed by Finalize() against a 5-file markdown corpus using a +// deterministic mock LLM provider, then asserts: +// - SQLite documents, chunks, embeddings row counts are in the +// expected bands, +// - entity / relationship counts reflect mock extraction, +// - a LocalSearch for a known substring returns >=1 hit from the +// correct document. +// +// Runs under the integration build tag so it stays out of the default +// `go test ./...` path; the CI test-integration job runs it with -race. +func TestPipeline_IndexAndSearch_EndToEnd(t *testing.T) { + t.Parallel() + + // 1. Temp dir for the SQLite DB (OpenForProject constructs + // /projects//docsiq.db for us). + dataDir := t.TempDir() + st, err := store.OpenForProject(dataDir, "itest") + if err != nil { + t.Fatalf("store.OpenForProject: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + // 2. Resolve the corpus relative to this test file; filepath.Abs + // resolves against the test binary's cwd which is the package dir + // (internal/pipeline), so ../../testdata/pipeline is correct. + corpus, err := filepath.Abs(filepath.Join("..", "..", "testdata", "pipeline")) + if err != nil { + t.Fatalf("resolve corpus: %v", err) + } + + // 3. Minimal config; values low to keep wall-clock predictable + // under -race. + cfg := &config.Config{ + DataDir: dataDir, + DefaultProject: "itest", + LLM: config.LLMConfig{Provider: "none"}, // unused; we inject the mock directly + Indexing: config.IndexingConfig{ + BatchSize: 4, + ChunkSize: 512, + ChunkOverlap: 64, + ExtractGraph: true, + ExtractClaims: false, + MaxGleanings: 0, + }, + Community: config.CommunityConfig{ + MinCommunitySize: 1, + MaxLevels: 2, + }, + } + + // 4. Mock provider — no network, deterministic. + prov := mock.New(mock.DefaultDims) + pl := pipeline.New(st, prov, cfg) + + // 5. Drive the indexer with a 120s deadline; a real deadlock will + // blow past this and fail loud. + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if err := pl.IndexPath(ctx, corpus, pipeline.IndexOptions{ + Workers: 2, + Verbose: false, + }); err != nil { + t.Fatalf("IndexPath: %v", err) + } + + // 6. Run Finalize (Phases 3-4: community detection + summaries). + if err := pl.Finalize(ctx, false); err != nil { + t.Fatalf("Finalize: %v", err) + } + + // 7. Row-count assertions — bands, not exact values, so chunker + // re-tuning doesn't break this test. + docCount := countRows(ctx, t, st, `SELECT count(*) FROM documents WHERE is_latest = 1`) + if docCount != 5 { + t.Errorf("document count: want 5, got %d", docCount) + } + + chunkCount := countRows(ctx, t, st, `SELECT count(*) FROM chunks`) + if chunkCount < 5 || chunkCount > 50 { + t.Errorf("chunk count: want 5..50, got %d", chunkCount) + } + + embCount := countRows(ctx, t, st, `SELECT count(*) FROM embeddings`) + if embCount != chunkCount { + t.Errorf("embedding count: want %d (= chunk count), got %d", chunkCount, embCount) + } + + // Mock returns 2 entities per extraction prompt; extractor runs + // at least once per chunk; dedup collapses duplicates. Lower + // bound 2, upper bound 2*chunkCount is a generous band. + entityCount := countRows(ctx, t, st, `SELECT count(*) FROM entities`) + if entityCount < 2 || entityCount > 2*chunkCount { + t.Errorf("entity count: want 2..%d, got %d", 2*chunkCount, entityCount) + } + + relCount := countRows(ctx, t, st, `SELECT count(*) FROM relationships`) + if relCount < 1 { + t.Errorf("relationship count: want >=1, got %d", relCount) + } + + // 8. Search assertion: "Apollo" appears in alpha.md, beta.md, + // gamma.md (indirectly via missions), and epsilon.md. LocalSearch + // must return >=1 chunk whose content references the corpus. + emb := embedder.New(prov, cfg.Indexing.BatchSize) + if emb == nil { + t.Fatal("embedder.New returned nil for non-nil provider") + } + result, err := search.LocalSearch(ctx, st, emb, nil, "Apollo program", 5, 0) + if err != nil { + t.Fatalf("LocalSearch: %v", err) + } + if len(result.Chunks) == 0 { + t.Fatal("LocalSearch returned 0 chunks; expected >=1") + } + var gotApollo bool + for _, c := range result.Chunks { + if strings.Contains(strings.ToLower(c.Chunk.Content), "apollo") { + gotApollo = true + break + } + } + if !gotApollo { + t.Errorf("LocalSearch returned %d chunks but none mentioned Apollo", len(result.Chunks)) + } +} + +// countRows runs a `SELECT count(*) ...` and fails the test on error. +func countRows(ctx context.Context, t *testing.T, st *store.Store, q string) int { + t.Helper() + var n int + if err := st.DB().QueryRowContext(ctx, q).Scan(&n); err != nil { + t.Fatalf("countRows %q: %v", q, err) + } + return n +} diff --git a/testdata/pipeline/alpha.md b/testdata/pipeline/alpha.md new file mode 100644 index 0000000..40c44da --- /dev/null +++ b/testdata/pipeline/alpha.md @@ -0,0 +1,10 @@ +# Alpha Doc + +Alpha is the first document in the fixture corpus. It mentions +Project Apollo, which was a spaceflight program run by NASA from 1961 +to 1972. Apollo 11 landed humans on the Moon for the first time. + +## Background + +The program was led by administrator James Webb, named after the +Greek god Apollo. The Saturn V rocket launched every mission. diff --git a/testdata/pipeline/beta.md b/testdata/pipeline/beta.md new file mode 100644 index 0000000..f01027d --- /dev/null +++ b/testdata/pipeline/beta.md @@ -0,0 +1,7 @@ +# Beta Doc + +Beta covers the Apollo program's missions in more detail. Apollo 11, +Apollo 12, and Apollo 13 are the most famous flights. Neil Armstrong +was the commander of Apollo 11 and the first human on the Moon. + +Apollo 13 suffered an oxygen tank explosion but returned safely. diff --git a/testdata/pipeline/delta.md b/testdata/pipeline/delta.md new file mode 100644 index 0000000..bbb4767 --- /dev/null +++ b/testdata/pipeline/delta.md @@ -0,0 +1,5 @@ +# Delta Doc + +Delta is a short document about unrelated topics. The moon is a +celestial body orbiting Earth. The Earth orbits the Sun. Neither is +particularly relevant to the Apollo program except by coincidence. diff --git a/testdata/pipeline/epsilon.md b/testdata/pipeline/epsilon.md new file mode 100644 index 0000000..aa1477b --- /dev/null +++ b/testdata/pipeline/epsilon.md @@ -0,0 +1,5 @@ +# Epsilon Doc + +Epsilon is the last fixture. It mentions James Webb again — the +James Webb Space Telescope was named after the Apollo-era NASA +administrator. diff --git a/testdata/pipeline/gamma.md b/testdata/pipeline/gamma.md new file mode 100644 index 0000000..fd78c30 --- /dev/null +++ b/testdata/pipeline/gamma.md @@ -0,0 +1,11 @@ +# Gamma Doc + +Gamma is about the Saturn V rocket. Saturn V was the largest rocket +ever flown successfully. It was designed by Wernher von Braun and his +team at the Marshall Space Flight Center. The rocket had three +stages. + +## Notable Flights + +All crewed Apollo missions used Saturn V. Skylab was launched on a +modified Saturn V. From eaa6257c32ea3ba2abbe484e25c6e8d45f877428 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:33:13 +0000 Subject: [PATCH 8/9] chore(go): bump toolchain 1.25.5 -> 1.25.7 to close stdlib CVEs Block 6's new govulncheck gate flagged two crypto/tls CVEs (GO-2026-4340, GO-2026-4337) in the 1.25.5 stdlib. 1.25.7 carries both fixes. Bump go.mod directive so setup-go picks the patched toolchain in CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 533297b..cebf93c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/RandomCodeSpace/docsiq -go 1.25.5 +go 1.25.7 require ( github.com/google/uuid v1.6.0 From 86f2e6aef889a387e811402aab9ee4139deffd29 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 02:42:42 +0000 Subject: [PATCH 9/9] chore(go): bump toolchain 1.25.7 -> 1.25.9 for additional stdlib CVEs Second govulncheck sweep surfaced crypto/x509 + archive/tar + os + net/url fixes landed in 1.25.8 and 1.25.9. Jump straight to 1.25.9 to close every known-reachable stdlib vuln in one commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cebf93c..c836a18 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/RandomCodeSpace/docsiq -go 1.25.7 +go 1.25.9 require ( github.com/google/uuid v1.6.0