diff --git a/.changeset/ci-diagnostics-symbol-refresh.md b/.changeset/ci-diagnostics-symbol-refresh.md new file mode 100644 index 0000000..303730f --- /dev/null +++ b/.changeset/ci-diagnostics-symbol-refresh.md @@ -0,0 +1,6 @@ +--- +"kibi-cli": patch +"kibi-mcp": patch +--- + +Add CI-only diagnostic logging for symbol coordinate refresh to help isolate the refreshCoordinatesForSymbolId coverage failure on GitHub Actions. diff --git a/.changeset/honor-sync-path-bootstrap.md b/.changeset/honor-sync-path-bootstrap.md deleted file mode 100644 index b5d28e9..0000000 --- a/.changeset/honor-sync-path-bootstrap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kibi-opencode": patch ---- - -Fix workspace health check to honor configured Kibi sync and documentation paths from `.kb/config.json`, preventing false bootstrap warnings when documentation directories have been relocated. diff --git a/.changeset/mcp-refresh-seam-isolation.md b/.changeset/mcp-refresh-seam-isolation.md new file mode 100644 index 0000000..4a9128e --- /dev/null +++ b/.changeset/mcp-refresh-seam-isolation.md @@ -0,0 +1,5 @@ +--- +"kibi-mcp": patch +--- + +Replace mock.module-based symbol refresh mocking in MCP upsert tests with a test seam to prevent cross-file leakages under coverage. diff --git a/.changeset/opencode-plugin-logging-remediation.md b/.changeset/opencode-plugin-logging-remediation.md deleted file mode 100644 index 87fc5da..0000000 --- a/.changeset/opencode-plugin-logging-remediation.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"kibi-opencode": patch ---- - -Quieter terminal behavior and logging correctness improvements: - -- Normal-operation logs (`info`/`warn`) now route through structured `client.app.log()` instead of `console.log`/`console.warn`, silenced when no host client is available. -- Error-class events (bootstrap-needed, sync/check failure, hook/init failure) remain visible in terminal via `console.error`. -- All `client.app.log()` calls are now fire-and-forget with `.catch(console.error)` to prevent unhandled Promise rejections. -- `PluginClient` interface is now exported so TypeScript declaration emit succeeds when `declaration: true`. -- Logger client is reset at the start of each plugin invocation to prevent client state leaking across multiple in-process instantiations. -- `system.transform` hook now appends only the guidance block to `output.system`, avoiding duplication of prior prompt entries. diff --git a/documentation/symbols.yaml b/documentation/symbols.yaml index 2be02bf..d72d490 100644 --- a/documentation/symbols.yaml +++ b/documentation/symbols.yaml @@ -1,9 +1,9 @@ # symbols.yaml # AUTHORED fields (edit freely): # id, title, sourceFile, links, status, tags, owner, priority -# GENERATED fields (never edit manually — overwritten by kibi sync and kb_symbols_refresh): +# GENERATED fields (never edit manually — overwritten by kibi sync and kb.symbols.refresh): # sourceLine, sourceColumn, sourceEndLine, sourceEndColumn, coordinatesGeneratedAt -# Run `kibi sync` or call the `kb_symbols_refresh` MCP tool to refresh coordinates. +# Run `kibi sync` or call the `kb.symbols.refresh` MCP tool to refresh coordinates. symbols: - id: SYM-001 title: PrologProcess @@ -20,7 +20,7 @@ symbols: sourceColumn: 13 sourceEndLine: 576 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.173Z' + coordinatesGeneratedAt: '2026-03-31T09:19:46.625Z' - id: SYM-002 title: handleKbUpsert sourceFile: packages/mcp/src/tools/upsert.ts @@ -36,7 +36,7 @@ symbols: sourceColumn: 22 sourceEndLine: 244 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.274Z' + coordinatesGeneratedAt: '2026-03-31T09:19:46.728Z' - id: SYM-003 title: handleKbQuery sourceFile: packages/mcp/src/tools/query.ts @@ -49,7 +49,7 @@ symbols: sourceColumn: 22 sourceEndLine: 91 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.275Z' + coordinatesGeneratedAt: '2026-03-31T09:19:46.729Z' - id: SYM-004 title: handleKbCheck sourceFile: packages/mcp/src/tools/check.ts @@ -65,7 +65,7 @@ symbols: sourceColumn: 22 sourceEndLine: 219 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.353Z' + coordinatesGeneratedAt: '2026-03-31T09:19:46.811Z' - id: SYM-005 title: KibiTreeDataProvider sourceFile: packages/vscode/src/treeProvider.ts @@ -81,7 +81,7 @@ symbols: sourceColumn: 13 sourceEndLine: 1002 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.481Z' + coordinatesGeneratedAt: '2026-03-31T09:19:46.978Z' - id: SYM-007 title: extractFromManifest sourceFile: packages/cli/src/extractors/manifest.ts @@ -94,7 +94,7 @@ symbols: sourceColumn: 16 sourceEndLine: 176 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.536Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.049Z' - id: SYM-010 title: startServer sourceFile: packages/mcp/src/server.ts @@ -107,7 +107,7 @@ symbols: sourceColumn: 22 sourceEndLine: 57 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.707Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.276Z' - id: SYM-KibiTreeDataProvider title: KibiTreeDataProvider sourceFile: packages/vscode/src/treeProvider.ts @@ -123,7 +123,7 @@ symbols: sourceColumn: 13 sourceEndLine: 1002 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.707Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.276Z' - id: SYM-KibiCodeActionProvider title: KibiCodeActionProvider sourceFile: packages/vscode/src/codeActionProvider.ts @@ -138,7 +138,7 @@ symbols: sourceColumn: 13 sourceEndLine: 106 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.708Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.277Z' - id: SYM-handleKbQueryRelationships title: handleKbQueryRelationships sourceFile: packages/mcp/src/tools/query-relationships.ts @@ -170,7 +170,7 @@ symbols: sourceColumn: 16 sourceEndLine: 91 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.710Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.278Z' - id: SYM-KibiCodeLensProvider title: KibiCodeLensProvider sourceFile: packages/vscode/src/codeLensProvider.ts @@ -185,7 +185,7 @@ symbols: sourceColumn: 13 sourceEndLine: 334 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-28T18:53:57.821Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.387Z' - id: SYM-mergeStaticLinks title: mergeStaticLinks sourceFile: packages/vscode/src/codeLensProvider.ts @@ -230,7 +230,7 @@ symbols: sourceColumn: 16 sourceEndLine: 102 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-30T09:23:50.000Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.464Z' - id: SYM-checkWorkspaceHealth title: checkWorkspaceHealth sourceFile: packages/opencode/src/workspace-health.ts @@ -241,8 +241,8 @@ symbols: target: REQ-opencode-kibi-plugin-v1 - type: covered_by target: TEST-opencode-kibi-plugin-v1 - sourceLine: 30 + sourceLine: 31 sourceColumn: 16 - sourceEndLine: 86 + sourceEndLine: 87 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-03-29T20:24:42.644Z' + coordinatesGeneratedAt: '2026-03-31T09:19:47.531Z' diff --git a/documentation/tests/TEST-mcp-upsert-coverage.md b/documentation/tests/TEST-mcp-upsert-coverage.md new file mode 100644 index 0000000..494797e --- /dev/null +++ b/documentation/tests/TEST-mcp-upsert-coverage.md @@ -0,0 +1,23 @@ +--- +id: TEST-mcp-upsert-coverage +title: MCP upsert handler unit coverage exercises validation and failure paths +status: active +created_at: 2026-03-30T00:00:00Z +updated_at: 2026-03-30T00:00:00Z +priority: must +tags: + - mcp + - test + - upsert + - coverage +source: packages/mcp/tests/tools/upsert.test.ts +links: + - REQ-002 + - REQ-011 +--- + +Validation steps: +- run `bun test packages/mcp/tests/tools/upsert.test.ts packages/mcp/tests/tools/upsert-contradictions.test.ts packages/mcp/tests/tools/crud.test.ts` +- run `bun test --coverage packages/mcp/tests/tools/upsert.test.ts packages/mcp/tests/tools/upsert-contradictions.test.ts packages/mcp/tests/tools/crud.test.ts` +- verify `packages/mcp/src/tools/upsert.ts` reports 100% line coverage +- verify mocked paths cover validation, contradiction formatting, audit/save failures, and symbol refresh warnings diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index c8e9d71..e92b0f5 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # kibi-cli +## 0.4.2 + +### Patch Changes + +- 7309d18: Export `__test__` helpers from `traceability/validate.ts` to enable unit testing of internal Prolog parsing utilities. + ## 0.4.1 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index b6a912e..8f08817 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "kibi-cli", - "version": "0.4.1", + "version": "0.4.2", "type": "module", "description": "Kibi CLI for knowledge base management", "engines": { diff --git a/packages/cli/src/extractors/symbols-coordinator.ts b/packages/cli/src/extractors/symbols-coordinator.ts index 1511646..dd8e3ad 100644 --- a/packages/cli/src/extractors/symbols-coordinator.ts +++ b/packages/cli/src/extractors/symbols-coordinator.ts @@ -40,6 +40,7 @@ export async function enrichSymbolCoordinates( entries: ManifestSymbolEntry[], workspaceRoot: string, ): Promise { + // implements REQ-vscode-traceability const output = entries.map((entry) => ({ ...entry })); const tsIndices: number[] = []; diff --git a/packages/cli/src/extractors/symbols-ts.ts b/packages/cli/src/extractors/symbols-ts.ts index 36eb529..341044e 100644 --- a/packages/cli/src/extractors/symbols-ts.ts +++ b/packages/cli/src/extractors/symbols-ts.ts @@ -62,13 +62,13 @@ export async function enrichSymbolCoordinatesWithTsMorph( entries: ManifestSymbolEntry[], workspaceRoot: string, ): Promise { + // implements REQ-vscode-traceability const project = new Project({ skipAddingFilesFromTsConfig: true, }); const sourceFileCache = new Map(); const enriched: ManifestSymbolEntry[] = []; - for (const entry of entries) { try { const resolved = resolveSourcePath(entry.sourceFile, workspaceRoot); diff --git a/packages/cli/src/traceability/validate.ts b/packages/cli/src/traceability/validate.ts index 3110e25..8d2d208 100644 --- a/packages/cli/src/traceability/validate.ts +++ b/packages/cli/src/traceability/validate.ts @@ -185,3 +185,11 @@ export function formatViolations(violations: Violation[]): string { } return lines.join("\n"); } + +// Test helpers for edge-case coverage +export const __test__ = { + unquoteAtom, + splitTopLevelComma, + splitTopLevelLists, + parsePrologListOfLists, +}; diff --git a/packages/cli/tests/commands/aggregated-checks.test.ts b/packages/cli/tests/commands/aggregated-checks.test.ts new file mode 100644 index 0000000..a1d436b --- /dev/null +++ b/packages/cli/tests/commands/aggregated-checks.test.ts @@ -0,0 +1,176 @@ +// @ts-ignore +import { describe, expect, test } from "bun:test"; +import { runAggregatedChecks } from "../../src/commands/aggregated-checks"; +import type { PrologProcess } from "../../src/prolog"; + +type QueryResult = { + success: boolean; + error?: string; + bindings?: Record; +}; + +function makeProlog(result: QueryResult, capture?: { lastQuery?: string }) { + const p = { + async query(q: string) { + if (capture) capture.lastQuery = q; + return result; + }, + }; + return p as unknown as PrologProcess; +} + +describe("runAggregatedChecks", () => { + test("successful check with violations returned", async () => { + const violations = { + "rule-a": [ + { + rule: "rule-a", + entityId: "E1", + description: "desc", + suggestion: "fix", + source: "src", + }, + ], + }; + + const result = { + success: true, + bindings: { JsonString: JSON.stringify(violations) }, + }; + + const prolog = makeProlog(result); + + const out = await runAggregatedChecks(prolog, null, false); + expect(out).toHaveLength(1); + expect(out[0]).toEqual({ + rule: "rule-a", + entityId: "E1", + description: "desc", + suggestion: "fix", + source: "src", + }); + }); + + test("empty violations (no issues found)", async () => { + const result = { + success: true, + bindings: { JsonString: JSON.stringify({}) }, + }; + const prolog = makeProlog(result); + const out = await runAggregatedChecks(prolog, null, false); + expect(out).toHaveLength(0); + }); + + test("double-encoded JSON parsing", async () => { + const violations = { + x: [ + { + rule: "x", + entityId: "id", + description: "d", + suggestion: "s", + source: "", + }, + ], + }; + const double = JSON.stringify(JSON.stringify(violations)); + const result = { success: true, bindings: { JsonString: double } }; + const prolog = makeProlog(result); + const out = await runAggregatedChecks(prolog, null, false); + expect(out[0].description).toBe("d"); + }); + + test("rules allowlist filtering", async () => { + const violations = { + bucket: [ + { + rule: "keep", + entityId: "A", + description: "a", + suggestion: "", + source: "", + }, + { + rule: "skip", + entityId: "B", + description: "b", + suggestion: "", + source: "", + }, + ], + }; + const result = { + success: true, + bindings: { JsonString: JSON.stringify(violations) }, + }; + const prolog = makeProlog(result); + const out = await runAggregatedChecks(prolog, new Set(["keep"]), false); + expect(out).toHaveLength(1); + expect(out[0].rule).toBe("keep"); + }); + + test("missing JsonString binding handling", async () => { + const result = { success: true, bindings: {} }; + const prolog = makeProlog(result); + await expect(runAggregatedChecks(prolog, null, false)).rejects.toThrow( + "No JSON string in binding", + ); + }); + + test("invalid JSON parsing error", async () => { + const result = { success: true, bindings: { JsonString: "not json" } }; + const prolog = makeProlog(result); + await expect(runAggregatedChecks(prolog, null, false)).rejects.toThrow( + "Failed to parse violations JSON", + ); + }); + + test("failed Prolog query handling", async () => { + const result = { success: false, error: "oom" }; + const prolog = makeProlog(result); + await expect(runAggregatedChecks(prolog, null, false)).rejects.toThrow( + /Aggregated checks query failed/, + ); + }); + + test("suggestion/source fields mapping undefined when empty", async () => { + const violations = { + r: [ + { + rule: "r", + entityId: "e", + description: "d", + suggestion: "", + source: "", + }, + ], + }; + const prolog = makeProlog({ + success: true, + bindings: { JsonString: JSON.stringify(violations) }, + }); + const out = await runAggregatedChecks(prolog, null, false); + expect(out[0].suggestion).toBeUndefined(); + expect(out[0].source).toBeUndefined(); + }); + + test("requireAdr flag passed to check_all_json_with_options (true and false)", async () => { + const violations = { r: [] }; + const capture: { lastQuery?: string } = {}; + const prologTrue = makeProlog( + { success: true, bindings: { JsonString: JSON.stringify(violations) } }, + capture, + ); + await runAggregatedChecks(prologTrue, null, true); + expect(capture.lastQuery).toContain("check_all_json_with_options"); + expect(capture.lastQuery).toContain("true"); + + const capture2: { lastQuery?: string } = {}; + const prologFalse = makeProlog( + { success: true, bindings: { JsonString: JSON.stringify(violations) } }, + capture2, + ); + await runAggregatedChecks(prologFalse, null, false); + expect(capture2.lastQuery).toContain("false"); + }); +}); diff --git a/packages/cli/tests/commands/discovery-shared.test.ts b/packages/cli/tests/commands/discovery-shared.test.ts new file mode 100644 index 0000000..4e8ef66 --- /dev/null +++ b/packages/cli/tests/commands/discovery-shared.test.ts @@ -0,0 +1,431 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import type { PrologProcess } from "../../src/prolog.js"; + +type QueryResult = { + success: boolean; + error?: string; + bindings?: Record; +}; + +type QueryableProlog = { + query: (goal: string) => Promise; +}; + +type MockPrologInstance = { + options: { timeout: number }; + useOneShotMode?: boolean; + start: ReturnType; + query: ReturnType; + terminate: ReturnType; +}; + +const state = { + currentBranch: "feature/test-branch", + throwCurrentBranch: false, + resolveKbPlPath: "/opt/kibi/core/kb.pl", + queryResponses: [] as Array, + queries: [] as string[], + cleanups: [] as Array, + createdPrologs: [] as MockPrologInstance[], +}; + +function resetState() { + state.currentBranch = "feature/test-branch"; + state.throwCurrentBranch = false; + state.resolveKbPlPath = "/opt/kibi/core/kb.pl"; + state.queryResponses = []; + state.queries = []; + state.cleanups = []; + state.createdPrologs = []; +} + +function setBranch(value?: string) { + process.env.KIBI_BRANCH = value ?? ""; +} + +function stripAnsi(value: string) { + return value.replace( + new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), + "", + ); +} + +class MockPrologProcess { + options: { timeout: number }; + useOneShotMode?: boolean; + start: ReturnType; + query: ReturnType; + terminate: ReturnType; + + constructor(options: { timeout: number }) { + this.options = options; + this.start = mock(async () => undefined); + this.query = mock(async (goal: string) => { + state.queries.push(goal); + const next = state.queryResponses.shift(); + if (next instanceof Error) { + throw next; + } + return next ?? { success: true, bindings: {} }; + }); + this.terminate = mock(async () => { + state.cleanups.push(this as unknown as MockPrologInstance); + return undefined; + }); + state.createdPrologs.push(this as unknown as MockPrologInstance); + } +} + +mock.module("../../src/prolog.js", () => ({ + PrologProcess: MockPrologProcess, + resolveKbPlPath: () => state.resolveKbPlPath, +})); + + +// Note: Tests use KIBI_BRANCH env var to control branch detection. +// When KIBI_BRANCH is set, getCurrentBranch is never called. +// When not set, the real getCurrentBranch runs (which returns "main" in CI). + +const discovery = await import("../../src/commands/discovery-shared.js"); + +describe("discovery-shared", () => { + let logSpy: ReturnType; + const originalBranch = process.env.KIBI_BRANCH; + + beforeEach(() => { + resetState(); + setBranch(); + logSpy = spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + setBranch(originalBranch); + }); + + test("withAttachedBranchProlog starts prolog, attaches branch KB, invokes callback, and cleans up", async () => { + process.env.KIBI_BRANCH = "feat/search"; + state.queryResponses = [ + { success: true }, + { success: true }, + { success: true, bindings: { ok: true } }, + ]; + + const result = await discovery.withAttachedBranchProlog(async (prolog) => { + const callbackResult = await prolog.query("user_goal"); + return callbackResult.bindings; + }); + + expect(JSON.stringify(result)).toBe(JSON.stringify({ ok: true })); + expect(state.createdPrologs).toHaveLength(1); + expect(state.createdPrologs[0]?.options).toEqual({ timeout: 120000 }); + expect(state.createdPrologs[0]?.start).toHaveBeenCalledTimes(1); + expect(state.queries[0]).toContain("set_prolog_flag(answer_write_options"); + expect(state.queries[1]).toContain("kb_attach('"); + expect(state.queries[1]).toContain(".kb/branches/feat/search"); + expect(state.queries[2]).toBe("user_goal"); + expect(state.cleanups).toEqual([state.createdPrologs[0]]); + }); + + test("withAttachedBranchProlog prefers env branch and falls back to main when branch detection fails", async () => { + process.env.KIBI_BRANCH = "env-branch"; + state.throwCurrentBranch = true; + state.queryResponses = [{ success: true }, { success: true }]; + + await discovery.withAttachedBranchProlog(async () => "done"); + expect(state.queries[1]).toContain(".kb/branches/env-branch"); + + setBranch(); + resetState(); + // Use env var to force main branch (avoid depending on actual git branch) + process.env.KIBI_BRANCH = "main"; + state.queryResponses = [{ success: true }, { success: true }]; + + await discovery.withAttachedBranchProlog(async () => "done"); + expect(state.queries[1]).toContain(".kb/branches/main"); + }); + + test("withAttachedBranchProlog throws attach failures and still cleans up", async () => { + state.queryResponses = [ + { success: true }, + { success: false, error: "attach exploded" }, + ]; + + await expect( + discovery.withAttachedBranchProlog(async () => "never"), + ).rejects.toThrow("Failed to attach KB: attach exploded"); + + expect(state.cleanups).toEqual([state.createdPrologs[0]]); + }); + + test("withPrologProcess enables one-shot mode, invokes callback, and cleans up on errors", async () => { + state.queryResponses = [{ success: true }]; + + await expect( + discovery.withPrologProcess(async (prolog) => { + expect((prolog as unknown as MockPrologInstance).useOneShotMode).toBe( + true, + ); + throw new Error("callback failed"); + }), + ).rejects.toThrow("callback failed"); + + expect(state.createdPrologs[0]?.options).toEqual({ timeout: 120000 }); + expect(state.queries[0]).toContain("set_prolog_flag(answer_write_options"); + expect(state.cleanups).toEqual([state.createdPrologs[0]]); + }); + + test("resolveCurrentKbPath uses current branch or main fallback", async () => { + // Use env var to control branch instead of mocking + process.env.KIBI_BRANCH = "topic/x"; + await expect(discovery.resolveCurrentKbPath()).resolves.toBe( + `${process.cwd()}/.kb/branches/topic/x`, + ); + + // Test fallback: clear env var and expect main + delete process.env.KIBI_BRANCH; + // Note: actual branch depends on git state, so we just verify it returns a path + const result = await discovery.resolveCurrentKbPath(); + expect(result).toMatch(/\.kb\/branches\//); + }); + + test("resolveCoreModulePath joins the requested file next to kb.pl", () => { + state.resolveKbPlPath = "/tmp/core/kb.pl"; + expect(discovery.resolveCoreModulePath("search_json.pl")).toBe( + "/tmp/core/search_json.pl", + ); + }); + + test("runJsonModuleQuery wraps module usage, optional kb attach, and parses nested JSON", async () => { + state.resolveKbPlPath = "/opt/kibi/core/kb.pl"; + const fakeProlog: QueryableProlog = { + query: mock(async (goal: string) => { + state.queries.push(goal); + return { + success: true, + bindings: { + JsonString: JSON.stringify(JSON.stringify({ rows: [1, 2] })), + }, + }; + }), + }; + + await expect( + discovery.runJsonModuleQuery( + fakeProlog as unknown as PrologProcess, + "nested\\coverage_json.pl", + "coverage_goal(JsonString)", + "Coverage failed", + "/tmp/kb/path", + ), + ).resolves.toEqual({ rows: [1, 2] }); + + expect(state.queries[0]).toContain( + "use_module('/opt/kibi/core/nested/coverage_json.pl')", + ); + expect(state.queries[0]).toContain("kb_attach('/tmp/kb/path')"); + expect(state.queries[0]).toContain("kb_detach"); + }); + + test("runJsonModuleQuery omits kb attach without kbPath and surfaces query/result errors", async () => { + const fakeProlog: QueryableProlog = { + query: mock(async (goal: string) => ({ + success: true, + bindings: { JsonString: '{"ok":true}' }, + })), + }; + + await expect( + discovery.runJsonModuleQuery( + fakeProlog as unknown as PrologProcess, + "status_json.pl", + "status_goal(JsonString)", + "Status failed", + ), + ).resolves.toEqual({ ok: true }); + expect(fakeProlog.query).toHaveBeenCalledWith( + "(use_module('/opt/kibi/core/status_json.pl'), status_goal(JsonString))", + ); + + await expect( + discovery.runJsonModuleQuery( + { + query: mock(async () => ({ success: false, error: "bad query" })), + } as unknown as PrologProcess, + "status_json.pl", + "status_goal(JsonString)", + "Status failed", + ), + ).rejects.toThrow("Status failed: bad query"); + + await expect( + discovery.runJsonModuleQuery( + { + query: mock(async () => ({ success: true, bindings: {} })), + } as unknown as PrologProcess, + "status_json.pl", + "status_goal(JsonString)", + "Status failed", + ), + ).rejects.toThrow("Status failed: missing JsonString binding"); + }); + + test("printDiscoveryResult emits JSON output and fallback text for unsupported payloads", () => { + discovery.printDiscoveryResult("json", { ok: true }, "fallback text"); + expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ ok: true }, null, 2)); + + discovery.printDiscoveryResult("table", null, "fallback text"); + expect(logSpy).toHaveBeenLastCalledWith("fallback text"); + + discovery.printDiscoveryResult( + "table", + { unsupported: true }, + "fallback text", + ); + expect(logSpy).toHaveBeenLastCalledWith("fallback text"); + }); + + test("printDiscoveryResult renders search and status tables", () => { + discovery.printDiscoveryResult( + "table", + { + count: 2, + results: [ + { + entity: { id: "REQ-1", type: "req", title: "Alpha" }, + score: 0.91, + reasons: ["title", "body"], + snippet: "matched text", + }, + { + entity: {}, + score: 0, + reasons: "not-an-array", + snippet: "", + }, + ], + }, + "fallback", + ); + const searchOutput = stripAnsi(logSpy.mock.calls.at(-1)?.[0] as string); + expect(searchOutput).toContain("Search results: 2 total"); + expect(searchOutput).toContain("REQ-1"); + expect(searchOutput).toContain("title, body"); + expect(searchOutput).toContain("│ -"); + + discovery.printDiscoveryResult( + "table", + { + branch: "feature/x", + syncState: "fresh", + dirty: false, + snapshotId: "snap-1", + syncedAt: "2026-03-30T00:00:00Z", + kbPath: "", + }, + "fallback", + ); + const statusOutput = stripAnsi(logSpy.mock.calls.at(-1)?.[0] as string); + expect(statusOutput).toContain("Branch"); + expect(statusOutput).toContain("feature/x"); + expect(statusOutput).toContain("false"); + expect(statusOutput).toContain("snap-1"); + expect(statusOutput).toContain("- "); + }); + + test("printDiscoveryResult renders graph, gaps, and both coverage table shapes", () => { + discovery.printDiscoveryResult( + "table", + { + nodes: [ + { id: "REQ-1", type: "req", title: "Requirement", status: "open" }, + ], + edges: [{ type: "implements", from: "SYM-1", to: "REQ-1" }], + truncated: true, + }, + "fallback", + ); + const graphOutput = stripAnsi(logSpy.mock.calls.at(-1)?.[0] as string); + expect(graphOutput).toContain("Nodes: 1 Edges: 1 Truncated: true"); + expect(graphOutput).toContain("implements"); + + discovery.printDiscoveryResult( + "table", + { + count: 1, + rows: [ + { + id: "REQ-2", + type: "req", + status: "open", + missingRelationships: [], + presentRelationships: ["verified_by"], + source: undefined, + }, + ], + }, + "fallback", + ); + const gapsOutput = stripAnsi(logSpy.mock.calls.at(-1)?.[0] as string); + expect(gapsOutput).toContain("Gap rows: 1"); + expect(gapsOutput).toContain("verified_by"); + expect(gapsOutput).toContain("- "); + + discovery.printDiscoveryResult( + "table", + { + summary: { covered: 1, missing: 0 }, + rows: [ + { + id: "REQ-3", + status: "open", + priority: "high", + coverageStatus: "covered", + scenarioCount: 1, + testCount: 2, + transitiveSymbolCount: 3, + gaps: ["none"], + }, + ], + }, + "fallback", + ); + const requirementCoverage = stripAnsi( + logSpy.mock.calls.at(-1)?.[0] as string, + ); + expect(requirementCoverage).toContain("covered"); + expect(requirementCoverage).toContain("Scen"); + expect(requirementCoverage).toContain("none"); + + discovery.printDiscoveryResult( + "table", + { + summary: { covered: 1 }, + rows: [ + { + id: "SYM-1", + type: "symbol", + coverageStatus: "partial", + directRequirementCount: 4, + testCount: 5, + count: 6, + gaps: [], + }, + ], + }, + "fallback", + ); + const genericCoverage = stripAnsi(logSpy.mock.calls.at(-1)?.[0] as string); + expect(genericCoverage).toContain("Details"); + expect(genericCoverage).toContain("req=4 test=5 count=6"); + expect(genericCoverage).toContain("partial"); + }); +}); diff --git a/packages/cli/tests/extractors/symbols-coordinator.test.ts b/packages/cli/tests/extractors/symbols-coordinator.test.ts new file mode 100644 index 0000000..d29f82d --- /dev/null +++ b/packages/cli/tests/extractors/symbols-coordinator.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, expect, it, mock } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; + +// Mutable stub — tests will set this to change behavior per-test +let tsEnrichStub: + | ((entries: any[], workspaceRoot: string) => Promise) + | null = null; + +// Install a module mock for the TS exporter before importing the coordinator. +mock.module("../../src/extractors/symbols-ts.js", () => ({ + enrichSymbolCoordinatesWithTsMorph: async ( + entries: any[], + workspaceRoot: string, + ) => { + if (tsEnrichStub) return tsEnrichStub(entries, workspaceRoot); + return entries; // default no-op + }, +})); + +// Dynamic import AFTER mock is set up +const { enrichSymbolCoordinates } = await import( + "../../src/extractors/symbols-coordinator.js" +); + +const tmpDir = path.join(process.cwd(), "tmp-symbols-coord-tests"); +function ensureDir() { + if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir); +} +function writeFile(name: string, content: string) { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content, "utf8"); + return p; +} + +beforeEach(() => { + // start from a clean directory for each test to avoid permission leftovers + if (fs.existsSync(tmpDir)) { + for (const f of fs.readdirSync(tmpDir)) { + try { + fs.chmodSync(path.join(tmpDir, f), 0o600); + } catch {} + try { + fs.unlinkSync(path.join(tmpDir, f)); + } catch {} + } + try { + fs.rmdirSync(tmpDir); + } catch {} + } + ensureDir(); +}); + +afterEach(() => { + if (fs.existsSync(tmpDir)) { + for (const f of fs.readdirSync(tmpDir)) { + try { + fs.chmodSync(path.join(tmpDir, f), 0o600); + } catch {} + fs.unlinkSync(path.join(tmpDir, f)); + } + fs.rmdirSync(tmpDir); + } + // reset mutable mock between tests + tsEnrichStub = null; +}); + +it("delegates TS/JS files to ts-morph exporter (ts and js) and resolves absolute/relative paths", async () => { + // stub ts enrichment via mock closure + tsEnrichStub = async (entries: any[]) => + entries.map((e, i) => ({ + ...e, + sourceLine: 10 + i, + sourceColumn: 1, + sourceEndLine: 10 + i, + sourceEndColumn: 5, + coordinatesGeneratedAt: new Date().toISOString(), + })); + + // create ts and js files so resolveSourcePath succeeds + const tsPath = writeFile("a.ts", "export function foo() {}\n"); + const jsPath = writeFile("b.js", "export function bar() {}\n"); + + const workspaceRoot = tmpDir; + + const entries = [ + { id: "e1", title: "foo", sourceFile: path.basename(tsPath) }, // relative path + { id: "e2", title: "bar", sourceFile: jsPath }, // absolute path + ]; + + const out = await enrichSymbolCoordinates(entries, workspaceRoot); + expect(out).toHaveLength(2); + // Both should be enriched by our stub + expect(out[0].sourceLine).toBe(10); + expect(out[1].sourceLine).toBe(11); +}); + +it("uses regex heuristic for non-TS files and returns original when no match", async () => { + const mdPath = writeFile( + "doc.md", + "first line\nmySymbol here and more\nlast\n", + ); + const noMatchPath = writeFile("other.txt", "nothing to see here\n"); + + const entries = [ + { id: "r1", title: "mySymbol", sourceFile: path.basename(mdPath) }, + { id: "r2", title: "absent", sourceFile: path.basename(noMatchPath) }, + ]; + + const out = await enrichSymbolCoordinates(entries, tmpDir); + expect(out[0].sourceLine).toBe(2); + expect(out[0].sourceColumn).toBeGreaterThanOrEqual(0); + // second should be unchanged (no coordinates added) + expect(out[1].sourceLine).toBeUndefined(); +}); + +it("returns original entry when file read throws (warns)", async () => { + // Use a missing file so resolveSourcePath returns null and coordinator returns original + const missing = `no-such-protected-${Date.now()}.txt`; + const entries = [{ id: "w1", title: "myX", sourceFile: missing }]; + + const out = await enrichSymbolCoordinates(entries, tmpDir); + expect(out[0].sourceLine).toBeUndefined(); +}); + +it("handles nonexistent sourceFile and returns original", async () => { + const entries = [{ id: "n1", title: "nope", sourceFile: "no/such/path.txt" }]; + const out = await enrichSymbolCoordinates(entries, tmpDir); + expect(out[0].sourceLine).toBeUndefined(); +}); + +it("properly escapes regex metacharacters in title when searching", async () => { + const trickyName = "funny(name).*+?^${}[]|\\"; + const content = + "line1\nthis has funny(name).*+?^${}[]|\\inside the middle\nend"; + const p = writeFile("tricky.txt", content); + + const entries = [ + { id: "t1", title: trickyName, sourceFile: path.basename(p) }, + ]; + const out = await enrichSymbolCoordinates(entries, tmpDir); + expect(out[0].sourceLine).toBe(2); +}); + +it("mixes multiple symbols with different file types", async () => { + // stub ts enrichment again + tsEnrichStub = async (entries: any[]) => + entries.map((e, i) => ({ + ...e, + sourceLine: 100 + i, + sourceColumn: 0, + sourceEndLine: 100 + i, + sourceEndColumn: 3, + coordinatesGeneratedAt: new Date().toISOString(), + })); + + const tsPath = writeFile("mix.ts", "export function a() {}\n"); + const mdPath = writeFile("mix.md", "alpha\nbeta\nGammaSymbol is here\n"); + + const entries = [ + { id: "m1", title: "a", sourceFile: path.basename(tsPath) }, + { id: "m2", title: "GammaSymbol", sourceFile: path.basename(mdPath) }, + ]; + + const out = await enrichSymbolCoordinates(entries, tmpDir); + expect(out[0].sourceLine).toBe(100); + expect(out[1].sourceLine).toBe(3); +}); diff --git a/packages/cli/tests/query/service.test.ts b/packages/cli/tests/query/service.test.ts new file mode 100644 index 0000000..13ea7be --- /dev/null +++ b/packages/cli/tests/query/service.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { PrologProcess } from "../../src/prolog.js"; + +const codecState = { + parsedLists: [] as string[][], + listEntities: [] as Array>, + bindingEntity: {} as Record, +}; + +const escapeAtomMock = mock((value: string) => value.replace(/'/g, "''")); +const parseListOfListsMock = mock((value: string) => { + void value; + return codecState.parsedLists; +}); +const parseEntityFromListMock = mock((value: string[]) => { + const index = codecState.parsedLists.findIndex((entry) => entry === value); + return codecState.listEntities[index] ?? {}; +}); +const parseEntityFromBindingMock = mock((value: string) => { + void value; + return codecState.bindingEntity; +}); + +mock.module("../../src/prolog/codec.js", () => ({ + escapeAtom: escapeAtomMock, + parseEntityFromBinding: parseEntityFromBindingMock, + parseEntityFromList: parseEntityFromListMock, + parseListOfLists: parseListOfListsMock, +})); + +const service = await import("../../src/query/service.js"); + +type QueryableProlog = { + query: (goal: string) => Promise<{ + success: boolean; + bindings?: Record; + error?: string; + }>; +}; + +function asPrologProcess(prolog: QueryableProlog): PrologProcess { + return prolog as unknown as PrologProcess; +} + +describe("query service", () => { + beforeEach(() => { + codecState.parsedLists = []; + codecState.listEntities = []; + codecState.bindingEntity = {}; + escapeAtomMock.mockClear(); + parseListOfListsMock.mockClear(); + parseEntityFromListMock.mockClear(); + parseEntityFromBindingMock.mockClear(); + }); + + describe("buildEntityQueryGoal", () => { + test("builds a sourceFile + type query with escaped atoms", () => { + const goal = service.buildEntityQueryGoal({ + sourceFile: "src/o'hare.ts", + type: "req's", + }); + + expect(goal).toBe( + "findall([Id,'req''s',Props], (kb_entities_by_source('src/o''hare.ts', SourceIds), member(Id, SourceIds), kb_entity(Id, 'req''s', Props)), Results)", + ); + expect(escapeAtomMock).toHaveBeenCalledTimes(2); + }); + + test("builds a sourceFile query without a type", () => { + expect( + service.buildEntityQueryGoal({ sourceFile: "src/query/service.ts" }), + ).toBe( + "findall([Id,Type,Props], (kb_entities_by_source('src/query/service.ts', SourceIds), member(Id, SourceIds), kb_entity(Id, Type, Props)), Results)", + ); + }); + + test("builds queries for id + type, id only, tags, type only, and fallback", () => { + expect(service.buildEntityQueryGoal({ id: "REQ-1", type: "req" })).toBe( + "findall(['REQ-1','req',Props], kb_entity('REQ-1', 'req', Props), Results)", + ); + expect(service.buildEntityQueryGoal({ id: "REQ-2" })).toBe( + "findall(['REQ-2',Type,Props], kb_entity('REQ-2', Type, Props), Results)", + ); + expect( + service.buildEntityQueryGoal({ tags: ["auth"], type: "symbol" }), + ).toBe( + "findall([Id,'symbol',Props], kb_entity(Id, 'symbol', Props), Results)", + ); + expect(service.buildEntityQueryGoal({ tags: ["auth"] })).toBe( + "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)", + ); + expect(service.buildEntityQueryGoal({ type: "fact" })).toBe( + "findall([Id,'fact',Props], kb_entity(Id, 'fact', Props), Results)", + ); + expect(service.buildEntityQueryGoal({})).toBe( + "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)", + ); + }); + }); + + describe("queryEntities", () => { + test("parses Results bindings, filters tags, deduplicates matches, and paginates", async () => { + codecState.parsedLists = [["a"], ["b"], ["c"], ["d"]]; + codecState.listEntities = [ + { id: "REQ-1", type: "req", tags: [" auth ", "core"] }, + { id: "REQ-1", type: "req", tags: ["auth"] }, + { id: "REQ-2", type: "req", tags: ["other"] }, + { id: "REQ-3", type: "req", tags: "auth" }, + ]; + const prolog: QueryableProlog = { + query: mock(async () => ({ + success: true, + bindings: { Results: "ignored" }, + })), + }; + + const result = await service.queryEntities(asPrologProcess(prolog), { + type: "req", + tags: ["auth"], + offset: 0, + limit: 10, + }); + + expect(prolog.query).toHaveBeenCalledWith( + "findall([Id,'req',Props], kb_entity(Id, 'req', Props), Results)", + ); + expect(parseListOfListsMock).toHaveBeenCalledWith("ignored"); + expect(parseEntityFromListMock).toHaveBeenCalledTimes(4); + expect(result).toEqual({ + entities: [{ id: "REQ-1", type: "req", tags: [" auth ", "core"] }], + totalCount: 1, + }); + }); + + test("parses a single Result binding", async () => { + codecState.bindingEntity = { + id: "REQ-9", + type: "req", + title: "Single", + }; + const prolog: QueryableProlog = { + query: mock(async () => ({ + success: true, + bindings: { Result: "one" }, + })), + }; + + expect(await service.queryEntities(asPrologProcess(prolog), {})).toEqual({ + entities: [{ id: "REQ-9", type: "req", title: "Single" }], + totalCount: 1, + }); + expect(parseEntityFromBindingMock).toHaveBeenCalledWith("one"); + }); + + test("returns an empty page when bindings are empty and applies default pagination", async () => { + const prolog: QueryableProlog = { + query: mock(async () => ({ + success: true, + bindings: {}, + })), + }; + + expect(await service.queryEntities(asPrologProcess(prolog), {})).toEqual({ + entities: [], + totalCount: 0, + }); + expect(prolog.query).toHaveBeenCalledWith( + "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)", + ); + }); + + test("applies offset and limit after filtering", async () => { + codecState.parsedLists = [["a"], ["b"], ["c"]]; + codecState.listEntities = [ + { id: "REQ-1", type: "req", tags: ["auth"] }, + { id: "REQ-2", type: "req", tags: ["auth"] }, + { id: "REQ-3", type: "req", tags: ["auth"] }, + ]; + const prolog: QueryableProlog = { + query: mock(async () => ({ + success: true, + bindings: { Results: "ignored" }, + })), + }; + + expect( + await service.queryEntities(asPrologProcess(prolog), { + tags: ["auth"], + offset: 1, + limit: 1, + }), + ).toEqual({ + entities: [{ id: "REQ-2", type: "req", tags: ["auth"] }], + totalCount: 3, + }); + }); + + test("throws the reported query error or a fallback message", async () => { + const failingProlog: QueryableProlog = { + query: mock(async () => ({ success: false, error: "boom" })), + }; + const unknownFailingProlog: QueryableProlog = { + query: mock(async () => ({ success: false, bindings: {} })), + }; + + try { + await service.queryEntities(asPrologProcess(failingProlog), {}); + throw new Error("expected queryEntities to reject"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("boom"); + } + + try { + await service.queryEntities(asPrologProcess(unknownFailingProlog), {}); + throw new Error("expected queryEntities to reject"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Query failed with unknown error", + ); + } + }); + }); + + test("validateEntityType accepts only supported types", () => { + expect(service.validateEntityType("req")).toBe(true); + expect(service.validateEntityType("unknown")).toBe(false); + }); + + test("getInvalidTypeError lists the supported entity types", () => { + expect(service.getInvalidTypeError("unknown")).toBe( + "Invalid type 'unknown'. Valid types: req, scenario, test, adr, flag, event, symbol, fact. Use a single type value, or omit this parameter to query all entities.", + ); + }); + + describe("buildQuerySummaryText", () => { + test("describes empty results", () => { + expect( + service.buildQuerySummaryText( + { entities: [], totalCount: 0 }, + { type: "req" }, + ), + ).toBe("No entities found of type 'req'."); + }); + + test("describes paginated results and strips file URI prefixes", () => { + const text = service.buildQuerySummaryText( + { + entities: [ + { + id: "file:///tmp/documentation/requirements/REQ-7.md", + title: "Readable title", + status: "open", + }, + { + id: "REQ-8", + title: undefined, + status: undefined, + }, + ], + totalCount: 2, + }, + { offset: 1, limit: 2 }, + ); + + expect(text).toBe( + "Found 2 entities. Showing 2 (offset 1, limit 2): REQ-7.md (Readable title, status=open), REQ-8 (, status=)", + ); + }); + }); +}); diff --git a/packages/cli/tests/traceability/git-staged.test.ts b/packages/cli/tests/traceability/git-staged.test.ts new file mode 100644 index 0000000..54d8c51 --- /dev/null +++ b/packages/cli/tests/traceability/git-staged.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getStagedFiles, + parseHunksFromDiff, + parseNameStatusNull, +} from "../../src/traceability/git-staged"; + +function makeExec( + responses: Record, + throws: Record = {}, +) { + return (cmd: string) => { + for (const [needle, error] of Object.entries(throws)) { + if (cmd.includes(needle)) throw error; + } + + for (const [needle, output] of Object.entries(responses)) { + if (cmd.includes(needle)) return output; + } + + throw new Error(`unexpected command: ${cmd}`); + }; +} + +function createTempRepo(): string { + const repoDir = mkdtempSync(join(tmpdir(), "kibi-git-staged-")); + execSync("git init", { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.name "Test User"', { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.email "test@example.com"', { + cwd: repoDir, + stdio: "pipe", + }); + return repoDir; +} + +describe("git-staged", () => { + const originalCwd = process.cwd(); + const originalTrace = process.env.KIBI_TRACE; + const originalDebug = process.env.KIBI_DEBUG; + + beforeEach(() => { + mock.restore(); + process.env.KIBI_TRACE = undefined; + process.env.KIBI_DEBUG = undefined; + process.chdir(originalCwd); + }); + + afterEach(() => { + process.chdir(originalCwd); + + if (originalTrace === undefined) process.env.KIBI_TRACE = undefined; + else process.env.KIBI_TRACE = originalTrace; + + if (originalDebug === undefined) process.env.KIBI_DEBUG = undefined; + else process.env.KIBI_DEBUG = originalDebug; + }); + + describe("parseNameStatusNull", () => { + it("returns an empty array for empty input", () => { + expect(parseNameStatusNull("")).toEqual([]); + }); + + it("parses tab-separated and null-delimited git status output", () => { + expect( + parseNameStatusNull( + "A\tfile.ts\0M\0file.js\0R100\0old.ts\0new.ts\0C50\0a.ts\0b.ts\0", + ), + ).toEqual([ + { status: "A", parts: ["file.ts"] }, + { status: "M", parts: ["file.js"] }, + { status: "R100", parts: ["old.ts", "new.ts"] }, + { status: "C50", parts: ["a.ts", "b.ts"] }, + ]); + }); + }); + + describe("parseHunksFromDiff", () => { + it("parses added ranges and ignores zero-length hunks", () => { + expect( + parseHunksFromDiff( + "@@ -1,2 +3,4 @@\n@@ -10,1 +12,0 @@\n@@ -20 +22 @@\n", + ), + ).toEqual([ + { start: 3, end: 6 }, + { start: 22, end: 22 }, + ]); + }); + + it("uses a sentinel range for new files without hunk headers", () => { + expect( + parseHunksFromDiff( + "diff --git a/new.ts b/new.ts\n--- /dev/null\n+++ b/new.ts\n", + true, + ), + ).toEqual([{ start: 1, end: Number.MAX_SAFE_INTEGER }]); + }); + }); + + describe("getStagedFiles", () => { + it("wraps git listing failures", () => { + const exec = makeExec({}, { "--name-status": new Error("boom") }); + expect(() => getStagedFiles(exec)).toThrow( + "failed to list staged files: Error: git command failed: git diff --cached --name-status -z --diff-filter=ACMRD -> boom", + ); + }); + + it("collects supported staged files, handles renames, and normalizes sentinel hunks", () => { + const exec = makeExec({ + "--name-status": + "A\0src/new file.ts\0R100\0src/old.ts\0src/renamed.ts\0M\0docs/requirements/REQ-123.md\0M\0nested/symbols.yaml\0\tblank-status.js\0", + 'git diff --cached -U0 -- "src/new file.ts"': + "diff --git a/src/new file.ts b/src/new file.ts\n--- /dev/null\n+++ b/src/new file.ts\n", + 'git diff --cached -U0 -- "src/renamed.ts"': + "@@ -1 +1,2 @@\n+renamed\n", + 'git diff --cached -U0 -- "docs/requirements/REQ-123.md"': + "@@ -1 +1 @@\n-title\n+title\n", + 'git diff --cached -U0 -- "nested/symbols.yaml"': + "diff --git a/nested/symbols.yaml b/nested/symbols.yaml\n--- /dev/null\n+++ b/nested/symbols.yaml\n", + 'git diff --cached -U0 -- "blank-status.js"': "@@ -0,0 +1 @@\n+ok\n", + 'git show :"src/new file.ts"': + "export const created = true;\nconsole.log(created);\n", + 'git show :"src/renamed.ts"': + "export function renamed() {}\nsecond line\n", + 'git show :"docs/requirements/REQ-123.md"': "---\nid: REQ-123\n---\n", + 'git show :"nested/symbols.yaml"': "symbols:\n - id: SYM-1\n", + 'git show :"blank-status.js"': "export default 1;\n", + }); + + expect(getStagedFiles(exec)).toEqual([ + { + path: "src/new file.ts", + status: "A", + oldPath: undefined, + hunkRanges: [{ start: 1, end: 3 }], + content: "export const created = true;\nconsole.log(created);\n", + }, + { + path: "src/renamed.ts", + status: "R", + oldPath: "src/old.ts", + hunkRanges: [{ start: 1, end: 2 }], + content: "export function renamed() {}\nsecond line\n", + }, + { + path: "docs/requirements/REQ-123.md", + status: "M", + oldPath: undefined, + hunkRanges: [{ start: 1, end: 1 }], + content: "---\nid: REQ-123\n---\n", + }, + { + path: "nested/symbols.yaml", + status: "M", + oldPath: undefined, + hunkRanges: [{ start: 1, end: 3 }], + content: "symbols:\n - id: SYM-1\n", + }, + { + path: "blank-status.js", + status: "M", + oldPath: undefined, + hunkRanges: [{ start: 1, end: 1 }], + content: "export default 1;\n", + }, + ]); + }); + + it("skips deleted, unsupported, and unreadable files while logging debug details", () => { + process.env.KIBI_DEBUG = "1"; + const debug = mock(() => {}); + const originalConsoleDebug = console.debug; + console.debug = debug; + + try { + const exec = makeExec( + { + "--name-status": "D\0gone.ts\0M\0notes.txt\0M\0broken.ts\0", + }, + { + 'git diff --cached -U0 -- "broken.ts"': new Error("diff failed"), + 'git show :"broken.ts"': new Error("binary file"), + }, + ); + + expect(getStagedFiles(exec)).toEqual([]); + expect( + debug.mock.calls.map((call) => String((call as unknown[])[0])), + ).toEqual([ + "Skipping deleted file (staged): gone.ts", + "Skipping unsupported extension: notes.txt", + expect.stringContaining("Failed to get diff for broken.ts"), + expect.stringContaining( + 'Skipping binary/deleted or unreadable staged file broken.ts: git command failed: git show :"broken.ts" -> binary file', + ), + ]); + } finally { + console.debug = originalConsoleDebug; + } + }); + + it("treats markdown outside entity directories as unsupported", () => { + const exec = makeExec({ + "--name-status": "M\0README.md\0", + }); + + expect(getStagedFiles(exec)).toEqual([]); + }); + + it("reads staged files from a real temporary git repository", () => { + const repoDir = createTempRepo(); + + try { + process.chdir(repoDir); + mkdirSync(join(repoDir, "src"), { recursive: true }); + writeFileSync( + join(repoDir, "src", "tracked.ts"), + "export function tracked() {} // implements REQ-014\n", + ); + + execSync("git add src/tracked.ts", { cwd: repoDir, stdio: "pipe" }); + + const files = getStagedFiles(); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: "src/tracked.ts", + status: "A", + hunkRanges: [{ start: 1, end: 1 }], + }); + expect(files[0]?.content).toContain("tracked"); + } finally { + process.chdir(originalCwd); + rmSync(repoDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/packages/cli/tests/traceability/markdown-validate.test.ts b/packages/cli/tests/traceability/markdown-validate.test.ts new file mode 100644 index 0000000..91020b1 --- /dev/null +++ b/packages/cli/tests/traceability/markdown-validate.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { FrontmatterError } from "../../src/extractors/markdown"; + +import { validateStagedMarkdown } from "../../src/traceability/markdown-validate"; + +function fm(obj: Record) { + const yaml = Object.entries(obj) + .map( + ([k, v]) => + `${k}: ${typeof v === "string" ? `"${v}"` : JSON.stringify(v)}`, + ) + .join("\n"); + return `---\n${yaml}\n---\n`; +} + +describe("validateStagedMarkdown", () => { + it("returns no errors for valid requirement frontmatter", () => { + const content = fm({ id: "REQ-1", title: "Req", status: "open" }); + const res = validateStagedMarkdown( + "/some/requirements/req.md", + content, + (v) => {}, + ); + expect(res.errors.length).toBe(0); + }); + + it("returns no errors for valid scenario frontmatter", () => { + const content = fm({ + id: "SCEN-1", + title: "Scen", + status: "draft", + type: "scenario", + }); + const res = validateStagedMarkdown( + "/some/scenarios/sc.md", + content, + () => {}, + ); + expect(res.errors.length).toBe(0); + }); + + it("returns no errors for valid test frontmatter", () => { + const content = fm({ + id: "TEST-1", + title: "T", + status: "pending", + type: "test", + }); + const res = validateStagedMarkdown("/some/tests/t.md", content, () => {}); + expect(res.errors.length).toBe(0); + }); + + it("handles missing fields gracefully (no embedded entities)", () => { + const content = fm({}); + const res = validateStagedMarkdown("/requirements/a.md", content); + expect(res.errors.length).toBe(0); + }); + + it("detects embedded scenario fields inside a requirement", () => { + const content = fm({ title: "X", scenarios: [{ given: "a" }] }); + const res = validateStagedMarkdown("/requirements/req.md", content); + expect(res.errors.length).toBe(1); + expect(res.errors[0]).toBeInstanceOf(FrontmatterError); + expect(String(res.errors[0].message)).toContain("Invalid embedded entity"); + }); + + it("handles generic parse errors without throwing", () => { + const content = "---\nfoo: [\n---\n"; + const res = validateStagedMarkdown("/requirements/req.md", content); + expect(res).toHaveProperty("filePath", "/requirements/req.md"); + expect(Array.isArray(res.errors)).toBe(true); + }); + + it("returns early for unknown path types", () => { + const content = fm({ title: "NoType" }); + const res = validateStagedMarkdown("/some/other/thing.md", content); + expect(res.errors.length).toBe(0); + }); +}); diff --git a/packages/cli/tests/traceability/symbol-extract.test.ts b/packages/cli/tests/traceability/symbol-extract.test.ts new file mode 100644 index 0000000..117757b --- /dev/null +++ b/packages/cli/tests/traceability/symbol-extract.test.ts @@ -0,0 +1,478 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Project } from "ts-morph"; + +const SYMBOL_EXTRACT_URL = new URL( + "../../src/traceability/symbol-extract.js", + import.meta.url, +).href; +const tempDirs: string[] = []; + +function loadSymbolExtractModule(tag: string) { + return import(`${SYMBOL_EXTRACT_URL}?case=${tag}-${Math.random()}`); +} + +function makeTempDir(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function makeStagedFile( + path: string, + content: string, + status: "A" | "M" | "R" = "M", +) { + return { + path, + content, + hunkRanges: [{ start: 1, end: 20 }], + status, + }; +} + +beforeEach(() => { + mock.restore(); +}); + +afterEach(() => { + mock.restore(); + for (const dir of tempDirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +describe("symbol-extract (real integration)", () => { + it("extracts symbols across script kinds and applies inline, manifest, hunk, and hash fallbacks", async () => { + const { extractSymbolsFromStagedFile } = + await loadSymbolExtractModule("real-script-kinds"); + + const tsDir = makeTempDir("symbol-extract-ts-"); + mkdirSync(join(tsDir, "src"), { recursive: true }); + const tsFile = join(tsDir, "src", "feature.ts"); + writeFileSync( + join(tsDir, "src", "symbols.yaml"), + [ + "symbols:", + " - id: SYM-CLS", + " title: FeatureClass", + " sourceFile: src/feature.ts", + " links:", + " - REQ-MANIFEST-CLASS", + " - id: SYM-ENUM", + " title: FeatureState", + " sourceFile: src/feature.ts", + " links:", + " - REQ-MANIFEST-ENUM", + " - id: SYM-VAR", + " title: FEATURE_VALUE", + " sourceFile: src/feature.ts", + " links:", + " - REQ-MANIFEST-VAR", + ].join("\n"), + ); + + const tsSymbols = extractSymbolsFromStagedFile({ + path: tsFile, + content: [ + "// implements: REQ-INLINE, REQ-INLINE, REQ_2", + "export function featureFn() {}", + "export class FeatureClass {}", + "export enum FeatureState { On }", + "export const FEATURE_VALUE = 1;", + "", + "export function skippedByHunk() {}", + ].join("\n"), + hunkRanges: [{ start: 1, end: 4 }], + status: "M", + }); + + expect(tsSymbols.map((symbol: { name: string }) => symbol.name)).toEqual([ + "featureFn", + "FeatureClass", + "FeatureState", + ]); + expect(tsSymbols[0]).toMatchObject({ + kind: "function", + reqLinks: ["REQ-INLINE", "REQ_2"], + hunkRanges: [{ start: 1, end: 4 }], + }); + expect(tsSymbols[1]).toMatchObject({ + id: "SYM-CLS", + kind: "class", + reqLinks: ["REQ-MANIFEST-CLASS"], + }); + expect(tsSymbols[2]).toMatchObject({ + id: "SYM-ENUM", + kind: "enum", + reqLinks: ["REQ-MANIFEST-ENUM"], + }); + + const jsDir = makeTempDir("symbol-extract-js-"); + mkdirSync(join(jsDir, "src"), { recursive: true }); + const jsFile = join(jsDir, "src", "plain.js"); + const jsSymbols = extractSymbolsFromStagedFile( + makeStagedFile(jsFile, "export function jsSymbol() {}", "A"), + ); + expect(jsSymbols).toHaveLength(1); + expect(jsSymbols[0]?.name).toBe("jsSymbol"); + expect(jsSymbols[0]?.id).toHaveLength(16); + + const jsxFile = join(jsDir, "src", "view.jsx"); + const jsxSymbols = extractSymbolsFromStagedFile( + makeStagedFile(jsxFile, "export const View = () =>
;", "A"), + ); + expect(jsxSymbols[0]).toMatchObject({ name: "View", kind: "variable" }); + + const tsxFile = join(jsDir, "src", "widget.tsx"); + const tsxSymbols = extractSymbolsFromStagedFile( + makeStagedFile( + tsxFile, + "export function Widget() { return
; }", + "A", + ), + ); + expect(tsxSymbols[0]).toMatchObject({ name: "Widget", kind: "function" }); + + const mtsFile = join(jsDir, "src", "module.mts"); + const mtsSymbols = extractSymbolsFromStagedFile( + makeStagedFile(mtsFile, "export const moduleValue = 1;", "A"), + ); + expect(mtsSymbols[0]).toMatchObject({ + name: "moduleValue", + kind: "variable", + }); + + const ctsFile = join(jsDir, "src", "common.cts"); + const ctsSymbols = extractSymbolsFromStagedFile( + makeStagedFile(ctsFile, "export enum CommonMode { On }", "R"), + ); + expect(ctsSymbols[0]).toMatchObject({ name: "CommonMode", kind: "enum" }); + }); + + it("ignores empty directive tokens when regex matches trailing whitespace", async () => { + const { extractSymbolsFromStagedFile } = await loadSymbolExtractModule( + "directive-empty-token", + ); + const originalExec = RegExp.prototype.exec; + let seenDirectiveRegex = false; + + RegExp.prototype.exec = function (text: string) { + if (this.source.includes("implements\\s*:?\\s*") && !seenDirectiveRegex) { + seenDirectiveRegex = true; + return Object.assign(["implements: REQ-ONLY ", "REQ-ONLY "], { + index: 0, + input: text, + }) as RegExpExecArray; + } + + if (this.source.includes("implements\\s*:?\\s*") && seenDirectiveRegex) { + return null; + } + + return originalExec.call(this, text); + }; + + const symbols = extractSymbolsFromStagedFile( + makeStagedFile("directive.ts", "export function directiveFn() {}", "A"), + ); + + RegExp.prototype.exec = originalExec; + + expect(symbols[0]?.reqLinks).toEqual(["REQ-ONLY"]); + }); +}); + +describe("symbol-extract (cache and failure branches)", () => { + it("reuses cache until TTL expires and preserves manifest lookup precedence", async () => { + const originalDateNow = Date.now; + const originalCreateSourceFile = Project.prototype.createSourceFile; + let now = 1_000; + Date.now = () => now; + + const createSourceFileCalls: Array<{ path: string; scriptKind: string }> = + []; + + const goodFunction = { + isExported: () => true, + getName: () => "lookupFn", + getNameNode: () => ({ getStart: () => 1 }), + getStart: () => 1, + getEnd: () => 3, + getFullText: () => "export function lookupFn() {}", + getJsDocs: () => [], + }; + + const sourceFile = { + getFunctions: () => [goodFunction], + getClasses: () => [], + getEnums: () => [], + getVariableStatements: () => [], + getLineAndColumnAtPos: (pos: number) => ({ line: pos, column: 1 }), + }; + + Project.prototype.createSourceFile = ( + path: string, + _content: string, + options?: unknown, + ) => { + createSourceFileCalls.push({ + path, + scriptKind: String( + (options as { scriptKind?: unknown } | undefined)?.scriptKind, + ), + }); + return sourceFile as never; + }; + + const { extractSymbolsFromStagedFile } = + await loadSymbolExtractModule("cache-ttl"); + const staged = { + path: "src/cache.ts", + content: "export function lookupFn() {}", + hunkRanges: [{ start: 1, end: 10 }], + status: "A" as const, + }; + const manifestLookup = new Map([ + ["src/cache.ts:lookupFn", { id: "SYM-CACHE", links: ["REQ-CACHE"] }], + ]); + + expect( + extractSymbolsFromStagedFile(staged, manifestLookup)[0], + ).toMatchObject({ + id: "SYM-CACHE", + reqLinks: ["REQ-CACHE"], + }); + expect(createSourceFileCalls).toHaveLength(1); + expect(createSourceFileCalls[0]?.scriptKind).not.toBe("undefined"); + + now += 10; + extractSymbolsFromStagedFile(staged, manifestLookup); + expect(createSourceFileCalls).toHaveLength(1); + + const missedLookup = extractSymbolsFromStagedFile(staged, new Map())[0]; + expect(missedLookup?.id).toHaveLength(16); + expect(missedLookup?.reqLinks).toEqual([]); + + now += 30_001; + extractSymbolsFromStagedFile(staged, manifestLookup); + expect(createSourceFileCalls).toHaveLength(2); + + Date.now = originalDateNow; + Project.prototype.createSourceFile = originalCreateSourceFile; + }); + + it("returns an empty list when parsing fails and caches null source files", async () => { + const originalDateNow = Date.now; + const originalCreateSourceFile = Project.prototype.createSourceFile; + let now = 5_000; + Date.now = () => now; + + let createSourceFileCalls = 0; + + Project.prototype.createSourceFile = () => { + createSourceFileCalls += 1; + throw new Error("boom"); + }; + + const { extractSymbolsFromStagedFile } = + await loadSymbolExtractModule("null-cache"); + const staged = makeStagedFile("broken.ts", "export function broken(", "M"); + + expect(extractSymbolsFromStagedFile(staged)).toEqual([]); + expect(extractSymbolsFromStagedFile(staged)).toEqual([]); + expect(createSourceFileCalls).toBe(1); + + now += 30_001; + expect(extractSymbolsFromStagedFile(staged)).toEqual([]); + expect(createSourceFileCalls).toBe(2); + + Date.now = originalDateNow; + Project.prototype.createSourceFile = originalCreateSourceFile; + }); + + it("skips malformed declarations, filters hunks, and falls back through manifest and hash branches", async () => { + const originalCreateSourceFile = Project.prototype.createSourceFile; + const manifestDir = makeTempDir("symbol-extract-mock-manifest-"); + const srcDir = join(manifestDir, "src"); + mkdirSync(srcDir, { recursive: true }); + writeFileSync( + join(srcDir, "symbols.yaml"), + [ + "symbols:", + " - id: SYM-CLASS", + " title: ManifestClass", + " links:", + " - REQ-FROM-MANIFEST", + " - not-valid", + " - type: relates_to", + " target: REQ-IGNORED", + " - id: SYM-ENUM", + " title: ManifestEnum", + " links:", + " - REQ-ENUM", + " - id: SYM-VAR", + " title: ManifestVar", + " links:", + " - REQ-VAR", + ].join("\n"), + ); + + const functionNode = { + isExported: () => true, + getName: () => "fnWithInlineReq", + getNameNode: () => ({ getStart: () => 1 }), + getStart: () => 1, + getEnd: () => 4, + getFullText: () => + "// implements: REQ-INLINE\nexport function fnWithInlineReq() {}", + getJsDocs: () => [{ getFullText: () => "/** ignored */" }], + }; + const brokenFunction = { + isExported: () => true, + getName: () => { + throw new Error("bad function"); + }, + }; + const hiddenFunction = { isExported: () => false }; + + const classNode = { + isExported: () => true, + getName: () => "ManifestClass", + getNameNode: () => ({ getStart: () => 5 }), + getStart: () => 5, + getEnd: () => 8, + getText: () => "export class ManifestClass {}", + getJsDocs: () => [{ getFullText: () => "/** docs */" }], + }; + const brokenClass = { + isExported: () => true, + getName: () => "BrokenClass", + getNameNode: () => ({ + getStart: () => { + throw new Error("bad class"); + }, + }), + getStart: () => 9, + getEnd: () => 10, + getText: () => "export class BrokenClass {}", + getJsDocs: () => [], + }; + const hiddenClass = { isExported: () => false }; + + const enumNode = { + isExported: () => true, + getName: () => "ManifestEnum", + getNameNode: () => ({ getStart: () => 9 }), + getStart: () => 9, + getEnd: () => 12, + getText: () => "export enum ManifestEnum { On }", + }; + const brokenEnum = { + isExported: () => true, + getName: () => "BrokenEnum", + getNameNode: () => ({ + getStart: () => { + throw new Error("bad enum"); + }, + }), + getStart: () => 12, + getEnd: () => 13, + getText: () => "export enum BrokenEnum { Off }", + }; + const hiddenEnum = { isExported: () => false }; + + const goodDeclaration = { + getName: () => "ManifestVar", + getNameNode: () => ({ getStart: () => 13 }), + getStart: () => 13, + getEnd: () => 14, + getText: () => "ManifestVar = 1", + }; + const brokenDeclaration = { + getName: () => { + throw new Error("bad var"); + }, + }; + const hashDeclaration = { + getName: () => "HashOnlyVar", + getNameNode: () => ({ getStart: () => 16 }), + getStart: () => 16, + getEnd: () => 17, + getText: () => "HashOnlyVar = 2", + }; + const exportedVariableStatement = { + isExported: () => true, + getDeclarations: () => [ + goodDeclaration, + brokenDeclaration, + hashDeclaration, + ], + }; + const hiddenVariableStatement = { + isExported: () => false, + getDeclarations: () => [], + }; + + const sourceFile = { + getFunctions: () => [functionNode, brokenFunction, hiddenFunction], + getClasses: () => [classNode, brokenClass, hiddenClass], + getEnums: () => [enumNode, brokenEnum, hiddenEnum], + getVariableStatements: () => [ + exportedVariableStatement, + hiddenVariableStatement, + ], + getLineAndColumnAtPos: (pos: number) => ({ line: pos, column: 1 }), + }; + + Project.prototype.createSourceFile = () => sourceFile as never; + + const { extractSymbolsFromStagedFile } = + await loadSymbolExtractModule("mocked-branches"); + + const modified = extractSymbolsFromStagedFile({ + path: join(srcDir, "feature.ts"), + content: "irrelevant", + hunkRanges: [{ start: 1, end: 14 }], + status: "M", + }); + + expect(modified).toHaveLength(4); + expect(modified.map((symbol: { name: string }) => symbol.name)).toEqual([ + "fnWithInlineReq", + "ManifestClass", + "ManifestEnum", + "ManifestVar", + ]); + expect(modified[0]).toMatchObject({ + reqLinks: ["REQ-INLINE"], + kind: "function", + }); + expect(modified[1]).toMatchObject({ + id: "SYM-CLASS", + reqLinks: ["REQ-FROM-MANIFEST"], + }); + expect(modified[2]).toMatchObject({ + id: "SYM-ENUM", + reqLinks: ["REQ-ENUM"], + }); + expect(modified[3]).toMatchObject({ id: "SYM-VAR", reqLinks: ["REQ-VAR"] }); + + const renamed = extractSymbolsFromStagedFile({ + path: "single-file.ts", + content: "irrelevant", + hunkRanges: [], + status: "R", + }); + expect( + renamed.some((symbol: { name: string }) => symbol.name === "HashOnlyVar"), + ).toBe(true); + expect( + renamed.find( + (symbol: { name: string; id: string }) => symbol.name === "HashOnlyVar", + )?.id, + ).toHaveLength(16); + Project.prototype.createSourceFile = originalCreateSourceFile; + }); +}); diff --git a/packages/cli/tests/traceability/validate.test.ts b/packages/cli/tests/traceability/validate.test.ts new file mode 100644 index 0000000..354a3e8 --- /dev/null +++ b/packages/cli/tests/traceability/validate.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "bun:test"; + +import { + type Violation, + __test__, + formatViolations, + validateStagedSymbols, +} from "../../src/traceability/validate"; + +describe("traceability/validate", () => { + it("exposes parser helpers for direct edge-case coverage", () => { + expect(__test__.unquoteAtom(" 'na''me' ")).toBe("na'me"); + expect(__test__.unquoteAtom(" plain_atom ")).toBe("plain_atom"); + + expect(__test__.splitTopLevelComma("'a''b', plain , 'c,d', final")).toEqual( + ["'a'b'", "plain", "'c,d'", "final"], + ); + + expect( + __test__.splitTopLevelLists( + "['a''b',1], ignored, ['c,d',2], ['unterminated''quote']", + ), + ).toEqual(["['a'b',1]", "['c,d',2]", "['unterminated'quote']"]); + + expect( + __test__.parsePrologListOfLists("[[a,1,'file.ts',10,0,'name']]"), + ).toEqual([["a", "1", "'file.ts'", "10", "0", "'name'"]]); + expect(__test__.parsePrologListOfLists("[]")).toEqual([]); + expect(__test__.parsePrologListOfLists("")).toEqual([]); + expect(__test__.parsePrologListOfLists("[[a,1],[b,2]]")).toEqual([ + ["a", "1"], + ["b", "2"], + ]); + expect( + __test__.parsePrologListOfLists("[[a,1], malformed, [b,2]]"), + ).toEqual([ + ["a", "1"], + ["b", "2"], + ]); + }); + + it("throws when the prolog query fails", async () => { + const prolog = { + query: async () => ({ success: false, error: "permission denied" }), + } as unknown as Parameters[0]["prolog"]; + + return expect( + validateStagedSymbols({ minLinks: 2, prolog }), + ).rejects.toThrow("Prolog query failed: permission denied"); + }); + + it("uses an unknown error message when the prolog failure has no error text", async () => { + const prolog = { + query: async () => ({ success: false }), + } as unknown as Parameters[0]["prolog"]; + + return expect( + validateStagedSymbols({ minLinks: 1, prolog }), + ).rejects.toThrow("Prolog query failed: unknown error"); + }); + + it("returns no violations when Rows is absent or empty", async () => { + const noRowsProlog = { + query: async () => ({ success: true, bindings: {} }), + } as unknown as Parameters[0]["prolog"]; + const emptyRowsProlog = { + query: async () => ({ success: true, bindings: { Rows: "" } }), + } as unknown as Parameters[0]["prolog"]; + + expect( + await validateStagedSymbols({ minLinks: 3, prolog: noRowsProlog }), + ).toEqual([]); + expect( + await validateStagedSymbols({ minLinks: 3, prolog: emptyRowsProlog }), + ).toEqual([]); + }); + + it("skips malformed rows and normalizes parsed values from prolog output", async () => { + const prolog = { + query: async (_goal: string) => ({ + success: true, + bindings: { + Rows: "[['sym''1',count9,'src/a,b.ts',line10,col03,'na''me'],[too,short],[plain_sym,countX,fileAtom,lineX,colX,nameAtom],ignored,['sym-2',7,' spaced.ts ',4,0,plain_name]]", + }, + }), + } as unknown as Parameters[0]["prolog"]; + + expect(await validateStagedSymbols({ minLinks: 4, prolog })).toEqual([ + { + symbolId: "plain_sym", + name: "nameAtom", + file: "fileAtom", + line: 0, + column: 0, + currentLinks: 0, + requiredLinks: 4, + }, + { + symbolId: "sym-2", + name: "plain_name", + file: " spaced.ts ", + line: 4, + column: 0, + currentLinks: 7, + requiredLinks: 4, + }, + ]); + }); + + it("parses quoted atoms for symbol ids, file paths, and names", async () => { + const prolog = { + query: async () => ({ + success: true, + bindings: { + Rows: "[[symQuoted,5,'src/quoted file.ts',8,2,'quoted''Name']]", + }, + }), + } as unknown as Parameters[0]["prolog"]; + + expect(await validateStagedSymbols({ minLinks: 2, prolog })).toEqual([ + { + symbolId: "symQuoted", + name: "quoted'Name", + file: "src/quoted file.ts", + line: 8, + column: 2, + currentLinks: 5, + requiredLinks: 2, + }, + ]); + }); + + it("formats violations into a human-readable report", () => { + expect( + formatViolations([ + { + symbolId: "SYM-1", + name: "alpha", + file: "src/alpha.ts", + line: 12, + column: 2, + currentLinks: 0, + requiredLinks: 2, + }, + { + symbolId: "SYM-2", + name: "beta", + file: "src/beta.ts", + line: 3, + column: 1, + currentLinks: 1, + requiredLinks: 2, + }, + ]), + ).toBe( + [ + "Traceability failed: 2/2 staged symbols unlinked (minLinks=2)", + "src/alpha.ts:12 alpha() -> Add one or more requirement links, for example: implements: REQ-001", + "src/beta.ts:3 beta() -> Add one or more requirement links, for example: implements: REQ-001", + ].join("\n"), + ); + }); + + it("returns an empty string for no violations and falls back to minLinks=0 when missing", () => { + expect(formatViolations([])).toBe(""); + expect( + formatViolations([ + { + symbolId: "SYM-3", + name: "gamma", + file: "src/gamma.ts", + line: 9, + column: 0, + currentLinks: 0, + } as Omit as Violation, + ]), + ).toContain("minLinks=0"); + }); +}); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index c0abc4a..66ad140 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # kibi-mcp +## 0.5.1 + +### Patch Changes + +- c0d09e0: Add comprehensive `kb_upsert` unit coverage for validation, encoding, transaction failure handling, and symbol coordinate refresh paths. +- Updated dependencies [7309d18] + - kibi-cli@0.4.2 + ## 0.5.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3772b6e..59751d3 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "kibi-mcp", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "ajv": "^8.18.0", @@ -9,7 +9,7 @@ "fast-glob": "^3.2.12", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", - "kibi-cli": "^0.4.0", + "kibi-cli": "^0.4.2", "kibi-core": "^0.3.0", "mcpcat": "^0.1.12", "ts-morph": "^23.0.0", diff --git a/packages/mcp/src/tools/symbols.ts b/packages/mcp/src/tools/symbols.ts index 5283fd3..773c62d 100644 --- a/packages/mcp/src/tools/symbols.ts +++ b/packages/mcp/src/tools/symbols.ts @@ -80,6 +80,7 @@ const SOURCE_EXTENSIONS = new Set([ export async function handleKbSymbolsRefresh( args: SymbolsRefreshArgs, ): Promise { + // implements REQ-vscode-traceability const dryRun = args.dryRun === true; const workspaceRoot = resolveWorkspaceRoot(); const manifestPath = resolveManifestPath(workspaceRoot); @@ -172,6 +173,7 @@ export async function refreshCoordinatesForSymbolId( symbolId: string, workspaceRoot: string = resolveWorkspaceRoot(), ): Promise<{ refreshed: boolean; found: boolean }> { + // implements REQ-vscode-traceability const manifestPath = resolveManifestPath(workspaceRoot); const rawContent = readFileSync(manifestPath, "utf8"); const parsed = parseYAML(rawContent); diff --git a/packages/mcp/src/tools/upsert.ts b/packages/mcp/src/tools/upsert.ts index 88e9541..ef7be68 100644 --- a/packages/mcp/src/tools/upsert.ts +++ b/packages/mcp/src/tools/upsert.ts @@ -26,6 +26,8 @@ import entitySchema from "kibi-cli/schemas/entity"; import relationshipSchema from "kibi-cli/schemas/relationship"; import { refreshCoordinatesForSymbolId } from "./symbols.js"; +let refreshCoordinatesForSymbolIdImpl = refreshCoordinatesForSymbolId; + export interface UpsertArgs { /** Entity type (req, scenario, test, adr, flag, event, symbol, fact) */ type: string; @@ -213,7 +215,7 @@ export async function handleKbUpsert( if (type === "symbol") { try { - await refreshCoordinatesForSymbolId(id); + await refreshCoordinatesForSymbolIdImpl(id); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (process.env.KIBI_MCP_DEBUG) { @@ -243,6 +245,15 @@ export async function handleKbUpsert( } } +export const __test__ = { + // implements REQ-vscode-traceability + setRefreshCoordinatesForSymbolIdForTests( + fn: typeof refreshCoordinatesForSymbolId | undefined, + ) { + refreshCoordinatesForSymbolIdImpl = fn ?? refreshCoordinatesForSymbolId; + }, +}; + /** * Build Prolog property list from entity object * Returns simple Key=Value format without typed literals @@ -277,6 +288,7 @@ function buildPropertyList(entity: Record): string { for (const [key, value] of Object.entries(entity)) { if (key === "type") continue; + if (value === undefined || value === null) continue; let prologValue: string; diff --git a/packages/mcp/tests/tools/delete.test.ts b/packages/mcp/tests/tools/delete.test.ts new file mode 100644 index 0000000..cc968c9 --- /dev/null +++ b/packages/mcp/tests/tools/delete.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { PrologProcess } from "kibi-cli/prolog"; +import { handleKbDelete } from "../../src/tools/delete.js"; + +type QueryResult = { + success: boolean; + bindings?: Record; + error?: string; +}; + +function createMockProlog( + handler: (goal: string) => Promise | QueryResult, +) { + const query = mock(async (goal: string) => { + const result = await handler(goal); + return { bindings: {}, ...result }; + }); + const invalidateCache = mock(() => {}); + + return { + query, + invalidateCache, + prolog: { + query, + invalidateCache, + } as unknown as PrologProcess, + }; +} + +describe("handleKbDelete", () => { + test("throws when ids array is empty", async () => { + const { prolog } = createMockProlog(async () => ({ success: true })); + + await expect(handleKbDelete(prolog, { ids: [] })).rejects.toThrow( + "At least one ID required for delete", + ); + }); + + test("deletes a single existing entity with no dependents and invalidates cache", async () => { + const { prolog, query, invalidateCache } = createMockProlog( + async (goal) => { + if (goal === "once(kb_entity('REQ-001', _, _))") { + return { success: true }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-001'")) { + return { success: true, bindings: { Dependents: "[]" } }; + } + + if (goal === "kb_retract_entity('REQ-001')") { + return { success: true }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }, + ); + + const result = await handleKbDelete(prolog, { ids: ["REQ-001"] }); + + expect(query).toHaveBeenCalledTimes(4); + expect(invalidateCache).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toEqual({ + deleted: 1, + skipped: 0, + errors: [], + }); + expect(result.content[0]?.text).toContain("Deleted 1 entities. Skipped 0."); + expect(result.content[0]?.text).not.toContain("Errors:"); + }); + + test("deletes multiple entities successfully and escapes quoted ids", async () => { + const { prolog, query } = createMockProlog(async (goal) => { + if ( + goal === "once(kb_entity('REQ-001', _, _))" || + goal === "once(kb_entity('o''brien', _, _))" + ) { + return { success: true }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-001'")) { + return { success: true, bindings: {} }; + } + + if (goal.includes("kb_relationship") && goal.includes("'o''brien'")) { + return { success: true, bindings: { Dependents: "[]" } }; + } + + if ( + goal === "kb_retract_entity('REQ-001')" || + goal === "kb_retract_entity('o''brien')" + ) { + return { success: true }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbDelete(prolog, { + ids: ["REQ-001", "o'brien"], + }); + + expect(query).toHaveBeenCalledWith("once(kb_entity('o''brien', _, _))"); + expect(query).toHaveBeenCalledWith("kb_retract_entity('o''brien')"); + expect(result.structuredContent).toEqual({ + deleted: 2, + skipped: 0, + errors: [], + }); + }); + + test("skips entities that do not exist", async () => { + const { prolog, query } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-404', _, _))") { + return { success: false }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbDelete(prolog, { ids: ["REQ-404"] }); + + expect(query).toHaveBeenCalledTimes(2); + expect(result.structuredContent).toEqual({ + deleted: 0, + skipped: 1, + errors: ["Entity REQ-404 does not exist"], + }); + }); + + test("skips entities that still have dependents", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-001', _, _))") { + return { success: true }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-001'")) { + return { + success: true, + bindings: { Dependents: "[[depends_on,'REQ-002']]" }, + }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbDelete(prolog, { ids: ["REQ-001"] }); + + expect(result.structuredContent).toEqual({ + deleted: 0, + skipped: 1, + errors: [ + "Cannot delete entity REQ-001: has dependents (other entities reference it)", + ], + }); + }); + + test("returns correct counts and aggregated errors for mixed results", async () => { + const { prolog } = createMockProlog(async (goal) => { + if ( + goal === "once(kb_entity('REQ-DELETED', _, _))" || + goal === "once(kb_entity('REQ-BLOCKED', _, _))" + ) { + return { success: true }; + } + + if (goal === "once(kb_entity('REQ-MISSING', _, _))") { + return { success: false }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-DELETED'")) { + return { success: true, bindings: { Dependents: "[]" } }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-BLOCKED'")) { + return { + success: true, + bindings: { Dependents: "[[verified_by,'TEST-1']]" }, + }; + } + + if (goal === "kb_retract_entity('REQ-DELETED')") { + return { success: true }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbDelete(prolog, { + ids: ["REQ-DELETED", "REQ-BLOCKED", "REQ-MISSING"], + }); + + expect(result.structuredContent).toEqual({ + deleted: 1, + skipped: 2, + errors: [ + "Cannot delete entity REQ-BLOCKED: has dependents (other entities reference it)", + "Entity REQ-MISSING does not exist", + ], + }); + expect(result.content[0]?.text).toContain("Deleted 1 entities. Skipped 2."); + expect(result.content[0]?.text).toContain( + "Errors: Cannot delete entity REQ-BLOCKED: has dependents (other entities reference it); Entity REQ-MISSING does not exist", + ); + }); + + test("reports dependent inspection query failures", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-001', _, _))") { + return { success: true }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-001'")) { + return { success: false }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbDelete(prolog, { ids: ["REQ-001"] }); + + expect(result.structuredContent).toEqual({ + deleted: 0, + skipped: 1, + errors: ["Failed to inspect dependents for entity REQ-001: Query failed"], + }); + }); + + test("reports delete query failures", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-001', _, _))") { + return { success: true }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-001'")) { + return { success: true, bindings: { Dependents: "[]" } }; + } + + if (goal === "kb_retract_entity('REQ-001')") { + return { success: false, error: "permission denied" }; + } + + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbDelete(prolog, { ids: ["REQ-001"] }); + + expect(result.structuredContent).toEqual({ + deleted: 0, + skipped: 1, + errors: ["Failed to delete entity REQ-001: permission denied"], + }); + }); + + test("wraps save failures and does not invalidate cache", async () => { + const { prolog, invalidateCache } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-001', _, _))") { + return { success: true }; + } + + if (goal.includes("kb_relationship") && goal.includes("'REQ-001'")) { + return { success: true, bindings: { Dependents: "[]" } }; + } + + if (goal === "kb_retract_entity('REQ-001')") { + return { success: true }; + } + + if (goal === "kb_save") { + return { success: false }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect(handleKbDelete(prolog, { ids: ["REQ-001"] })).rejects.toThrow( + "Delete execution failed: Failed to save KB after delete: Unknown error", + ); + expect(invalidateCache).not.toHaveBeenCalled(); + }); + + test("wraps thrown non-Error values from the query layer", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-001', _, _))") { + throw "catastrophic failure"; + } + + return { success: true }; + }); + + await expect(handleKbDelete(prolog, { ids: ["REQ-001"] })).rejects.toThrow( + "Delete execution failed: catastrophic failure", + ); + }); +}); diff --git a/packages/mcp/tests/tools/entity-query.test.ts b/packages/mcp/tests/tools/entity-query.test.ts new file mode 100644 index 0000000..8c6e64f --- /dev/null +++ b/packages/mcp/tests/tools/entity-query.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { PrologProcess } from "kibi-cli/prolog"; +import { + VALID_ENTITY_TYPES, + buildEntityGoal, + dedupeEntities, + loadEntities, + paginateResults, + validateEntityType, +} from "../../src/tools/entity-query.js"; + +describe("entity-query helpers", () => { + describe("validateEntityType", () => { + test("accepts undefined and every supported entity type", () => { + expect(() => validateEntityType()).not.toThrow(); + + for (const type of VALID_ENTITY_TYPES) { + expect(() => validateEntityType(type)).not.toThrow(); + } + }); + + test("throws for invalid entity type", () => { + expect(() => validateEntityType("invalid")).toThrow( + `Invalid type 'invalid'. Valid types: ${VALID_ENTITY_TYPES.join(", ")}. Use a single type value, or omit this parameter to query all entities.`, + ); + }); + }); + + describe("buildEntityGoal", () => { + test("builds sourceFile and type goal with escaped atoms", () => { + expect( + buildEntityGoal({ + sourceFile: "src/o'brien.ts", + type: "req", + }), + ).toBe( + "findall([Id,'req',Props], (kb_entities_by_source('src/o''brien.ts', SourceIds), member(Id, SourceIds), kb_entity(Id, 'req', Props)), Results)", + ); + }); + + test("builds sourceFile-only goal", () => { + expect(buildEntityGoal({ sourceFile: "src/tools/entity-query.ts" })).toBe( + "findall([Id,Type,Props], (kb_entities_by_source('src/tools/entity-query.ts', SourceIds), member(Id, SourceIds), kb_entity(Id, Type, Props)), Results)", + ); + }); + + test("builds id and type goal", () => { + expect(buildEntityGoal({ id: "REQ-1", type: "req" })).toBe( + "findall(['REQ-1','req',Props], kb_entity('REQ-1', 'req', Props), Results)", + ); + }); + + test("builds id-only goal", () => { + expect(buildEntityGoal({ id: "REQ-1" })).toBe( + "findall(['REQ-1',Type,Props], kb_entity('REQ-1', Type, Props), Results)", + ); + }); + + test("builds tags and type goal", () => { + expect(buildEntityGoal({ tags: ["alpha"], type: "fact" })).toBe( + "findall([Id,'fact',Props], kb_entity(Id, 'fact', Props), Results)", + ); + }); + + test("builds tags-only goal", () => { + expect(buildEntityGoal({ tags: ["alpha", "beta"] })).toBe( + "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)", + ); + }); + + test("builds type-only goal for every entity type", () => { + for (const type of VALID_ENTITY_TYPES) { + expect(buildEntityGoal({ type })).toBe( + `findall([Id,'${type}',Props], kb_entity(Id, '${type}', Props), Results)`, + ); + } + }); + + test("builds all-entities goal by default", () => { + expect(buildEntityGoal({})).toBe( + "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)", + ); + }); + }); + + describe("paginateResults", () => { + test("uses default pagination values", () => { + expect(paginateResults([1, 2, 3])).toEqual([1, 2, 3]); + }); + + test("applies limit and offset", () => { + expect(paginateResults([0, 1, 2, 3, 4], 2, 1)).toEqual([1, 2]); + }); + }); + + describe("dedupeEntities", () => { + test("deduplicates by type and id only", () => { + expect( + dedupeEntities([ + { id: "same", type: "req", title: "first" }, + { id: "same", type: "req", title: "second" }, + { id: "same", type: "fact", title: "different type" }, + { title: "missing keys" }, + { title: "missing keys duplicate" }, + ]), + ).toEqual([ + { id: "same", type: "req", title: "first" }, + { id: "same", type: "fact", title: "different type" }, + { title: "missing keys" }, + ]); + }); + }); +}); + +describe("loadEntities", () => { + const mockQuery = mock( + async (): Promise<{ + success: boolean; + bindings: Record; + error?: string; + }> => ({ success: true, bindings: {} }), + ); + + const mockProlog = { + query: mockQuery, + } as unknown as PrologProcess; + + beforeEach(() => { + mockQuery.mockReset(); + }); + + test("loads a single entity from Result binding", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Result: '[req1,req,[title="One",status=open,tags=[alpha]]]', + }, + }); + + const results = await loadEntities(mockProlog, { type: "req" }); + + expect(results).toEqual([ + { + id: "req1", + type: "req", + title: "One", + status: "open", + tags: ["alpha"], + }, + ]); + expect(mockQuery).toHaveBeenCalledWith( + "findall([Id,'req',Props], kb_entity(Id, 'req', Props), Results)", + ); + }); + + test("loads multiple entities from Results binding", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Results: + '[[req1,req,[title="One",status=open]],[req2,req,[title="Two",status=closed]]]', + }, + }); + + const results = await loadEntities(mockProlog, { type: "req" }); + + expect(results).toEqual([ + { id: "req1", type: "req", title: "One", status: "open" }, + { id: "req2", type: "req", title: "Two", status: "closed" }, + ]); + }); + + test("filters by a single tag and removes duplicate matches", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Results: + '[[fact1,fact,[title="Tagged",status=active,tags=[" alpha ",beta]]],[fact1,fact,[title="Tagged",status=active,tags=[" alpha ",beta]]],[fact2,fact,[title="Untagged",status=active]]]', + }, + }); + + const results = await loadEntities(mockProlog, { tags: ["alpha"] }); + + expect(results).toEqual([ + { + id: "fact1", + type: "fact", + title: "Tagged", + status: "active", + tags: [" alpha ", "beta"], + }, + ]); + }); + + test("filters by multiple tags and returns any matching entity once", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Results: + '[[req1,req,[title="Alpha",status=open,tags=[alpha]]],[scen1,scenario,[title="Beta",status=active,tags=[beta]]],[test1,test,[title="None",status=passing,tags=[gamma]]]]', + }, + }); + + const results = await loadEntities(mockProlog, { tags: ["alpha", "beta"] }); + + expect(results).toEqual([ + { + id: "req1", + type: "req", + title: "Alpha", + status: "open", + tags: ["alpha"], + }, + { + id: "scen1", + type: "scenario", + title: "Beta", + status: "active", + tags: ["beta"], + }, + ]); + }); + + test("returns no entities when tag filtering finds no matches", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Results: + '[[req1,req,[title="Alpha",status=open,tags=[alpha]]],[req2,req,[title="No Tags",status=open]]]', + }, + }); + + const results = await loadEntities(mockProlog, { tags: ["missing"] }); + + expect(results).toEqual([]); + }); + + test("returns empty results when query succeeds without bindings", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: {}, + }); + + const results = await loadEntities(mockProlog, {}); + + expect(results).toEqual([]); + }); + + test("deduplicates repeated entities without tag filtering", async () => { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Results: + '[[evt1,event,[title="Event",status=active]],[evt1,event,[title="Event",status=active]],[sym1,symbol,[title="Symbol",status=active]]]', + }, + }); + + const results = await loadEntities(mockProlog, {}); + + expect(results).toEqual([ + { id: "evt1", type: "event", title: "Event", status: "active" }, + { id: "sym1", type: "symbol", title: "Symbol", status: "active" }, + ]); + }); + + test("supports every documented entity type", async () => { + for (const type of VALID_ENTITY_TYPES) { + mockQuery.mockResolvedValueOnce({ + success: true, + bindings: { + Result: `[${type}1,${type},[title="${type}",status=active]]`, + }, + }); + + const results = await loadEntities(mockProlog, { type }); + + expect(results).toEqual([ + { id: `${type}1`, type, title: type, status: "active" }, + ]); + } + }); + + test("throws on invalid entity type before querying", async () => { + let error: unknown; + + try { + await loadEntities(mockProlog, { type: "invalid" }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `Invalid type 'invalid'. Valid types: ${VALID_ENTITY_TYPES.join(", ")}. Use a single type value, or omit this parameter to query all entities.`, + ); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + test("throws when the prolog query fails with an explicit error", async () => { + mockQuery.mockResolvedValueOnce({ + success: false, + bindings: {}, + error: "Prolog Error", + }); + + let error: unknown; + + try { + await loadEntities(mockProlog, {}); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Prolog Error"); + }); + + test("throws a default error when the query fails without details", async () => { + mockQuery.mockResolvedValueOnce({ + success: false, + bindings: {}, + }); + + let error: unknown; + + try { + await loadEntities(mockProlog, {}); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Query failed with unknown error"); + }); +}); diff --git a/packages/mcp/tests/tools/symbols.test.ts b/packages/mcp/tests/tools/symbols.test.ts index 39cb962..360e477 100644 --- a/packages/mcp/tests/tools/symbols.test.ts +++ b/packages/mcp/tests/tools/symbols.test.ts @@ -402,4 +402,3 @@ describe("refreshCoordinatesForSymbolId — internal declaration shapes (regress expect(updated).toContain("coordinatesGeneratedAt:"); }); }); - diff --git a/packages/mcp/tests/tools/upsert.test.ts b/packages/mcp/tests/tools/upsert.test.ts new file mode 100644 index 0000000..6c4aab3 --- /dev/null +++ b/packages/mcp/tests/tools/upsert.test.ts @@ -0,0 +1,854 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { PrologProcess } from "kibi-cli/prolog"; +import { __test__, handleKbUpsert } from "../../src/tools/upsert.js"; + +type QueryResult = { + success: boolean; + bindings?: Record; + error?: string; +}; + +const initialKibiMcpDebug: string | undefined = process.env.KIBI_MCP_DEBUG; + +function createMockProlog( + handler: (goal: string) => Promise | QueryResult, +) { + const query = mock(async (goal: string) => { + const result = await handler(goal); + return { bindings: {}, ...result }; + }); + const invalidateCache = mock(() => {}); + + return { + query, + invalidateCache, + prolog: { + query, + invalidateCache, + } as unknown as PrologProcess, + }; +} + +afterEach(() => { + mock.restore(); + __test__.setRefreshCoordinatesForSymbolIdForTests(undefined); + if (initialKibiMcpDebug === undefined) { + process.env.KIBI_MCP_DEBUG = undefined; + } else { + process.env.KIBI_MCP_DEBUG = initialKibiMcpDebug; + } +}); + +describe("handleKbUpsert", () => { + test("rejects missing required type/id arguments", async () => { + const { prolog, query } = createMockProlog(async () => ({ success: true })); + + await expect( + handleKbUpsert(prolog, { + type: "", + id: "", + properties: {}, + }), + ).rejects.toThrow("'type' and 'id' are required for upsert"); + + expect(query).not.toHaveBeenCalled(); + }); + + test("rejects invalid entity payloads before querying Prolog", async () => { + const { prolog, query } = createMockProlog(async () => ({ success: true })); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-INVALID-ENTITY", + properties: { + title: "Invalid entity", + status: "not-a-real-status", + source: "test://upsert", + }, + }), + ).rejects.toThrow(/Entity validation failed/); + + expect(query).not.toHaveBeenCalled(); + }); + + test("rejects invalid relationship payloads before querying Prolog", async () => { + const { prolog, query } = createMockProlog(async () => ({ success: true })); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-INVALID-REL", + properties: { + title: "Invalid relationship", + status: "open", + source: "test://upsert", + }, + relationships: [{ type: "specified_by", from: "REQ-INVALID-REL" }], + }), + ).rejects.toThrow(/Relationship validation failed at index 0/); + + expect(query).not.toHaveBeenCalled(); + }); + + test("rejects relationships whose source does not match the upserted entity", async () => { + const { prolog, query } = createMockProlog(async () => ({ success: true })); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-SOURCE-MISMATCH", + properties: { + title: "Source mismatch", + status: "open", + source: "test://upsert", + }, + relationships: [ + { + type: "specified_by", + from: "REQ-OTHER", + to: "SCEN-001", + }, + ], + }), + ).rejects.toThrow( + /Relationship source must match the upserted entity REQ-SOURCE-MISMATCH/, + ); + + expect(query).not.toHaveBeenCalled(); + }); + + test("rejects constrains relationships targeting property_value facts", async () => { + const { prolog, query } = createMockProlog(async (goal) => { + if (goal.includes("normalize_term_atom(_SlpFK, property_value)")) { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-STRICT-CONSTRAINS", + properties: { + title: "Invalid strict constrains", + status: "open", + source: "test://upsert", + }, + relationships: [ + { + type: "constrains", + from: "REQ-STRICT-CONSTRAINS", + to: "FACT-PROP-001", + }, + ], + }), + ).rejects.toThrow(/Property_value facts cannot be direct targets/); + + expect(query).toHaveBeenCalledTimes(1); + }); + + test("rejects requires_property relationships targeting subject facts", async () => { + const { prolog, query } = createMockProlog(async (goal) => { + if (goal.includes("normalize_term_atom(_SlpFK, subject)")) { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-STRICT-PROPERTY", + properties: { + title: "Invalid strict property", + status: "open", + source: "test://upsert", + }, + relationships: [ + { + type: "requires_property", + from: "REQ-STRICT-PROPERTY", + to: "FACT-SUBJECT-001", + }, + ], + }), + ).rejects.toThrow(/Subject facts cannot be direct targets/); + + expect(query).toHaveBeenCalledTimes(1); + }); + + test("supports requirement upserts with relationships and contradiction checks in one transaction", async () => { + const { prolog, query, invalidateCache } = createMockProlog( + async (goal) => { + if (goal.includes("normalize_term_atom(_SlpFK, property_value)")) { + return { success: false }; + } + if (goal.includes("normalize_term_atom(_SlpFK, subject)")) { + return { success: false }; + } + if (goal === "once(kb_entity('REQ-WITH-RELS', _, _))") { + return { success: false }; + } + if ( + goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,") && + goal.includes( + "kb_assert_relationship_no_audit(constrains, 'REQ-WITH-RELS', 'FACT-SUBJECT-001', [])", + ) && + goal.includes( + "kb_assert_relationship_no_audit(requires_property, 'REQ-WITH-RELS', 'FACT-PROP-001', [])", + ) && + goal.includes("check_req_contradiction('REQ-WITH-RELS')") + ) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_relationship_upsert(constrains,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_relationship_upsert(requires_property,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }, + ); + + const result = await handleKbUpsert(prolog, { + type: "req", + id: "REQ-WITH-RELS", + properties: { + title: "Requirement with relationships", + status: "open", + source: "test://upsert", + }, + relationships: [ + { + type: "constrains", + from: "REQ-WITH-RELS", + to: "FACT-SUBJECT-001", + }, + { + type: "requires_property", + from: "REQ-WITH-RELS", + to: "FACT-PROP-001", + }, + ], + }); + + expect(query).toHaveBeenCalledTimes(8); + expect(invalidateCache).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toEqual({ + created: 1, + updated: 0, + relationships_created: 2, + }); + }); + + test("defaults source and includes contradiction checks for requirement upserts", async () => { + const { prolog, query, invalidateCache } = createMockProlog( + async (goal) => { + if (goal === "once(kb_entity('REQ-DEFAULT-SOURCE', _, _))") { + return { success: false }; + } + if ( + goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,") + ) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }, + ); + + const result = await handleKbUpsert(prolog, { + type: "req", + id: "REQ-DEFAULT-SOURCE", + properties: { + title: "Default source", + status: "open", + }, + }); + + const transactionGoal = query.mock.calls.find(([goal]) => + String(goal).startsWith("rdf_transaction"), + )?.[0] as string | undefined; + + expect(transactionGoal).toContain('source="mcp://kibi/upsert"'); + expect(transactionGoal).toContain( + "check_req_contradiction('REQ-DEFAULT-SOURCE')", + ); + expect(invalidateCache).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toEqual({ + created: 1, + updated: 0, + relationships_created: 0, + }); + }); + + test("encodes entity properties across atom, string, array, number, boolean, and fallback values", async () => { + const { prolog, query } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('FACT-ENCODE-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(fact,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(fact,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await handleKbUpsert(prolog, { + type: "fact", + id: "FACT-ENCODE-001", + properties: { + title: "Encoded fact", + status: "active", + source: "test://upsert", + fact_kind: "property_value", + subject_key: "user.session", + property_key: "timeout_seconds", + operator: "eq", + value_type: "int", + value_int: 30, + closed_world: false, + tags: ["alpha", "beta"], + owner: undefined, + text_ref: "docs/requirements.md#L1", + }, + }); + + const transactionGoal = query.mock.calls.find(([goal]) => + String(goal).startsWith("rdf_transaction"), + )?.[0] as string | undefined; + + expect(transactionGoal).toContain("id='FACT-ENCODE-001'"); + expect(transactionGoal).toContain('title="Encoded fact"'); + expect(transactionGoal).toContain("status=active"); + expect(transactionGoal).toContain("fact_kind=property_value"); + expect(transactionGoal).toContain('subject_key="user.session"'); + expect(transactionGoal).toContain('property_key="timeout_seconds"'); + expect(transactionGoal).toContain("operator=eq"); + expect(transactionGoal).toContain("value_type=int"); + expect(transactionGoal).toContain("value_int=30"); + expect(transactionGoal).toContain("closed_world=false"); + expect(transactionGoal).toContain('tags=["alpha","beta"]'); + expect(transactionGoal).not.toContain("owner="); + expect(transactionGoal).toContain('text_ref="docs/requirements.md#L1"'); + }); + + test("encodes relationship metadata and skips contradiction checks when requested", async () => { + const { prolog, query } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-REL-META-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_relationship_upsert(")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbUpsert(prolog, { + type: "req", + id: "REQ-REL-META-001", + properties: { + title: "Relationship metadata", + status: "open", + source: "test://upsert", + }, + relationships: [ + { + type: "specified_by", + from: "REQ-REL-META-001", + to: "SCEN-001", + created_at: "2026-03-30T10:00:00Z", + created_by: "tester", + source: undefined, + confidence: 0.5, + }, + { + type: "verified_by", + from: "REQ-REL-META-001", + to: "TEST-001", + }, + ], + _skipContradictionCheck: true, + }); + + const transactionGoal = query.mock.calls.find(([goal]) => + String(goal).startsWith("rdf_transaction"), + )?.[0] as string | undefined; + + expect(transactionGoal).not.toContain("check_req_contradiction"); + expect(transactionGoal).toContain( + `kb_assert_relationship_no_audit(specified_by, 'REQ-REL-META-001', 'SCEN-001', [created_at="2026-03-30T10:00:00Z", created_by="tester", source="undefined", confidence=0.5])`, + ); + expect(transactionGoal).toContain( + `kb_assert_relationship_no_audit(verified_by, 'REQ-REL-META-001', 'TEST-001', [])`, + ); + expect(query).toHaveBeenCalledWith( + `kb_log_relationship_upsert(specified_by, 'REQ-REL-META-001', 'SCEN-001', [created_at="2026-03-30T10:00:00Z", created_by="tester", source="undefined", confidence=0.5])`, + ); + expect(result.structuredContent).toEqual({ + created: 1, + updated: 0, + relationships_created: 2, + }); + }); + + test("reports updates when the entity already exists", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-UPDATED-001', _, _))") { + return { success: true }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbUpsert(prolog, { + type: "req", + id: "REQ-UPDATED-001", + properties: { + title: "Updated req", + status: "open", + source: "test://upsert", + }, + }); + + expect(result.content[0]?.text).toContain("updated"); + expect(result.structuredContent).toEqual({ + created: 0, + updated: 1, + relationships_created: 0, + }); + }); + + test("deduplicates contradiction details in formatted transaction errors", async () => { + const { prolog, invalidateCache } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-CONTRA-DEDUPE', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { + success: false, + error: + "kb_contradiction(['subject user.session property timeout_seconds'-'REQ-OLD-001', 'subject user.session property timeout_seconds'-'REQ-OLD-001', 'subject user.session property ttl_seconds'-'REQ-OLD-002'])", + }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + let message = ""; + try { + await handleKbUpsert(prolog, { + type: "req", + id: "REQ-CONTRA-DEDUPE", + properties: { + title: "Conflicting req", + status: "open", + source: "test://upsert", + }, + }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toContain( + "Contradiction detected for requirement REQ-CONTRA-DEDUPE", + ); + expect(message.match(/REQ-OLD-001/g)?.length).toBe(1); + expect(message).toContain("REQ-OLD-002"); + expect(message).toContain("Add a supersedes relationship"); + expect(invalidateCache).not.toHaveBeenCalled(); + }); + + test("falls back to a generic contradiction message when conflict details cannot be parsed", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-CONTRA-FALLBACK', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { + success: false, + error: "kb_contradiction([unparsed_conflict_term])", + }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-CONTRA-FALLBACK", + properties: { + title: "Fallback contradiction", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow( + "Contradiction detected for entity REQ-CONTRA-FALLBACK: This requirement conflicts with existing requirements.", + ); + }); + + test("formats raw rdf_transaction errors without exposing the full goal", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-TX-FAIL-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { + success: false, + error: + "rdf_transaction((kb_assert_entity_no_audit(req, [..]))) failed", + }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-TX-FAIL-001", + properties: { + title: "Transaction failure", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow( + "Upsert execution failed: Failed to upsert entity REQ-TX-FAIL-001: Transaction failed", + ); + }); + + test("formats plain transaction errors without exposing raw goals", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-TX-PLAIN-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { + success: false, + error: "permission denied", + }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-TX-PLAIN-001", + properties: { + title: "Plain transaction failure", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow( + "Upsert execution failed: Failed to upsert entity REQ-TX-PLAIN-001: permission denied", + ); + }); + + test("formats unknown transaction errors when Prolog returns no details", async () => { + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-TX-FAIL-UNKNOWN', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { + success: false, + error: undefined, + }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-TX-FAIL-UNKNOWN", + properties: { + title: "Unknown transaction failure", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow( + "Upsert execution failed: Failed to upsert entity REQ-TX-FAIL-UNKNOWN: Unknown error", + ); + }); + + test("wraps entity audit failures", async () => { + const { prolog, invalidateCache } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-AUDIT-FAIL-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: false, error: "entity audit broke" }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-AUDIT-FAIL-001", + properties: { + title: "Entity audit failure", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow( + "Upsert execution failed: Failed to record audit entry for REQ-AUDIT-FAIL-001: entity audit broke", + ); + + expect(invalidateCache).not.toHaveBeenCalled(); + }); + + test("wraps relationship audit failures", async () => { + const { prolog, invalidateCache } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-REL-AUDIT-FAIL-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_relationship_upsert(specified_by,")) { + return { success: false, error: "relationship audit broke" }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-REL-AUDIT-FAIL-001", + properties: { + title: "Relationship audit failure", + status: "open", + source: "test://upsert", + }, + relationships: [ + { + type: "specified_by", + from: "REQ-REL-AUDIT-FAIL-001", + to: "SCEN-FAIL-001", + }, + ], + _skipContradictionCheck: true, + }), + ).rejects.toThrow( + "Upsert execution failed: Failed to record relationship audit entry REQ-REL-AUDIT-FAIL-001->SCEN-FAIL-001: relationship audit broke", + ); + + expect(invalidateCache).not.toHaveBeenCalled(); + }); + + test("wraps save failures after invalidating the cache", async () => { + const { prolog, invalidateCache } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('REQ-SAVE-FAIL-001', _, _))") { + return { success: false }; + } + if (goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(req,")) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(req,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: false, error: "disk full" }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-SAVE-FAIL-001", + properties: { + title: "Save failure", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow( + "Upsert execution failed: Failed to save KB after upsert: disk full", + ); + + expect(invalidateCache).toHaveBeenCalledTimes(1); + }); + + test("refreshes symbol coordinates after a successful symbol upsert", async () => { + const refreshCoordinatesForSymbolId = mock(async () => ({ + refreshed: true, + found: true, + })); + __test__.setRefreshCoordinatesForSymbolIdForTests( + refreshCoordinatesForSymbolId, + ); + process.env.KIBI_MCP_DEBUG = "1"; + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('SYM-REFRESH-001', _, _))") { + return { success: false }; + } + if ( + goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(symbol,") + ) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(symbol,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbUpsert(prolog, { + type: "symbol", + id: "SYM-REFRESH-001", + properties: { + title: "Refresh me", + status: "active", + source: "test://upsert", + }, + }); + + expect(refreshCoordinatesForSymbolId).toHaveBeenCalledWith( + "SYM-REFRESH-001", + ); + expect(warnSpy).not.toHaveBeenCalled(); + expect(result.structuredContent?.created).toBe(1); + }); + + test("warns instead of failing when symbol coordinate refresh throws in debug mode", async () => { + const refreshCoordinatesForSymbolId = mock(async () => { + throw "refresh blew up"; + }); + __test__.setRefreshCoordinatesForSymbolIdForTests( + refreshCoordinatesForSymbolId, + ); + process.env.KIBI_MCP_DEBUG = "1"; + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + const { prolog } = createMockProlog(async (goal) => { + if (goal === "once(kb_entity('SYM-REFRESH-WARN-001', _, _))") { + return { success: false }; + } + if ( + goal.startsWith("rdf_transaction((kb_assert_entity_no_audit(symbol,") + ) { + return { success: true }; + } + if (goal.startsWith("kb_log_entity_upsert(symbol,")) { + return { success: true }; + } + if (goal === "kb_save") { + return { success: true }; + } + + throw new Error(`Unexpected goal: ${goal}`); + }); + + const result = await handleKbUpsert(prolog, { + type: "symbol", + id: "SYM-REFRESH-WARN-001", + properties: { + title: "Warn me", + status: "active", + source: "test://upsert", + }, + }); + + expect(refreshCoordinatesForSymbolId).toHaveBeenCalledWith( + "SYM-REFRESH-WARN-001", + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Symbol coordinate auto-refresh failed for SYM-REFRESH-WARN-001: refresh blew up", + ), + ); + expect(result.structuredContent?.created).toBe(1); + }); + + test("wraps non-Error exceptions raised during execution", async () => { + const query = mock(async () => { + throw "string failure"; + }); + const invalidateCache = mock(() => {}); + const prolog = { + query, + invalidateCache, + } as unknown as PrologProcess; + + await expect( + handleKbUpsert(prolog, { + type: "req", + id: "REQ-STRING-FAIL-001", + properties: { + title: "String failure", + status: "open", + source: "test://upsert", + }, + }), + ).rejects.toThrow("Upsert execution failed: string failure"); + }); +}); diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index f41e37c..ab0a6c5 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,19 @@ # kibi-opencode +## 0.5.3 + +### Patch Changes + +- 6df5858: Fix workspace health check to honor configured Kibi sync and documentation paths from `.kb/config.json`, preventing false bootstrap warnings when documentation directories have been relocated. +- 051bdc3: Quieter terminal behavior and logging correctness improvements: + + - Normal-operation logs (`info`/`warn`) now route through structured `client.app.log()` instead of `console.log`/`console.warn`, silenced when no host client is available. + - Error-class events (bootstrap-needed, sync/check failure, hook/init failure) remain visible in terminal via `console.error`. + - All `client.app.log()` calls are now fire-and-forget with `.catch(console.error)` to prevent unhandled Promise rejections. + - `PluginClient` interface is now exported so TypeScript declaration emit succeeds when `declaration: true`. + - Logger client is reset at the start of each plugin invocation to prevent client state leaking across multiple in-process instantiations. + - `system.transform` hook now appends only the guidance block to `output.system`, avoiding duplication of prior prompt entries. + ## 0.5.2 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a610500..bc692eb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "name": "kibi-opencode", - "version": "0.5.2", + "version": "0.5.3", "description": "Kibi OpenCode plugin - thin adapter to integrate Kibi with OpenCode sessions", "type": "module", "main": "dist/index.js", diff --git a/packages/opencode/tests/logger.test.ts b/packages/opencode/tests/logger.test.ts new file mode 100644 index 0000000..ad4164f --- /dev/null +++ b/packages/opencode/tests/logger.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as logger from "../src/logger"; + +describe("opencode/logger", () => { + beforeEach(() => { + logger.resetClient(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + logger.resetClient(); + vi.restoreAllMocks(); + }); + + it("setClient stores client correctly and resetClient clears it", () => { + const mockClient = { app: { log: vi.fn().mockResolvedValue(undefined) } }; + logger.setClient(mockClient as any); + logger.info("hello"); + logger.resetClient(); + expect(() => logger.info("after-reset")).not.toThrow(); + }); + + it("info() with client calls client.app.log with correct payload", async () => { + const mockLog = vi.fn().mockResolvedValue(undefined); + const mockClient = { app: { log: mockLog } }; + logger.setClient(mockClient as any); + + logger.info("my-info"); + + await Promise.resolve(); + + expect(mockLog).toHaveBeenCalledTimes(1); + const arg = mockLog.mock.calls[0][0] as any; + expect(arg).toHaveProperty("body"); + expect(arg.body).toMatchObject({ + service: "kibi-opencode", + level: "info", + message: "my-info", + }); + }); + + it("info() without client is silent and does not throw", () => { + expect(() => logger.info("no-client")).not.toThrow(); + }); + + it("warn() with client calls client.app.log with correct payload", async () => { + const mockLog = vi.fn().mockResolvedValue(undefined); + const mockClient = { app: { log: mockLog } }; + logger.setClient(mockClient as any); + + logger.warn("my-warn"); + await Promise.resolve(); + + expect(mockLog).toHaveBeenCalledTimes(1); + const arg = mockLog.mock.calls[0][0] as any; + expect(arg.body).toMatchObject({ + service: "kibi-opencode", + level: "warn", + message: "my-warn", + }); + }); + + it("warn() without client is silent and does not throw", () => { + expect(() => logger.warn("no-client-warn")).not.toThrow(); + }); + + it("error() with client calls console.error and client.app.log", async () => { + const mockLog = vi.fn().mockResolvedValue(undefined); + const mockClient = { app: { log: mockLog } }; + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + logger.setClient(mockClient as any); + + logger.error("fatal"); + await Promise.resolve(); + + expect(spy).toHaveBeenCalledWith("[kibi-opencode]", "fatal"); + expect(mockLog).toHaveBeenCalledTimes(1); + const arg = mockLog.mock.calls[0][0] as any; + expect(arg.body).toMatchObject({ + service: "kibi-opencode", + level: "error", + message: "fatal", + }); + }); + + it("error() without client still calls console.error", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + logger.resetClient(); + logger.error("only-console"); + expect(spy).toHaveBeenCalledWith("[kibi-opencode]", "only-console"); + }); + + it("handles client.app.log rejection gracefully and logs the rejection to console.error", async () => { + const err = new Error("boom"); + const mockLog = vi.fn().mockRejectedValue(err); + const mockClient = { app: { log: mockLog } }; + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + logger.setClient(mockClient as any); + logger.info("will-reject"); + + await Promise.resolve(); + + expect(spy).toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledTimes(1); + }); + + it("multiple log calls in sequence work as expected", async () => { + const mockLog = vi.fn().mockResolvedValue(undefined); + const mockClient = { app: { log: mockLog } }; + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + logger.setClient(mockClient as any); + + logger.info("i1"); + logger.warn("w1"); + logger.error("e1"); + + await Promise.resolve(); + + expect(mockLog).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenCalledWith("[kibi-opencode]", "e1"); + }); +}); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index dd7d183..22bc6eb 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # kibi-vscode +## 0.2.3 + +### Patch Changes + +- 7309d18: Fix cross-test mock leakage by adding dependency injection seam to `KibiHoverProvider`. The constructor now accepts an optional 4th parameter `deps?: Partial` for injecting CLI executor and markdown builder functions during testing. + ## 0.2.2 ### Patch Changes diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 10fa4dd..c14ad10 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,13 +2,21 @@ "name": "kibi-vscode", "displayName": "Kibi Knowledge Base", "description": "VS Code extension for Kibi knowledge base with TreeView and MCP integration", - "version": "0.2.2", + "version": "0.2.3", "publisher": "kibi", "engines": { "vscode": "^1.74.0" }, - "categories": ["Other"], - "keywords": ["knowledge base", "requirements", "adr", "scenarios", "mcp"], + "categories": [ + "Other" + ], + "keywords": [ + "knowledge base", + "requirements", + "adr", + "scenarios", + "mcp" + ], "activationEvents": [ "onStartupFinished", "onView:kibi-knowledge-base", @@ -106,7 +114,9 @@ "servers": { "kibi": { "command": "bun", - "args": ["${config:kibi.mcp.serverPath}"], + "args": [ + "${config:kibi.mcp.serverPath}" + ], "env": {} } } diff --git a/packages/vscode/src/hoverProvider.ts b/packages/vscode/src/hoverProvider.ts index 52f2da9..ec958ee 100644 --- a/packages/vscode/src/hoverProvider.ts +++ b/packages/vscode/src/hoverProvider.ts @@ -34,16 +34,38 @@ interface EntityCacheEntry { timestamp: number; } +// implements REQ-vscode-traceability +interface HoverProviderDeps { + execCli(command: string, options?: any): string; + buildMarkdown( + symbol: { id: string; title: string; file: string; line: number }, + entities: EntityDetails[], + ): string; +} + +const defaultDeps: HoverProviderDeps = { + execCli: (cmd, opts) => execSync(cmd, opts as any) as unknown as string, + buildMarkdown: (sym, ents) => buildHoverMarkdown(sym, ents), +}; + +// implements REQ-vscode-traceability export class KibiHoverProvider implements vscode.HoverProvider { private entityDetailsCache = new Map(); private entityInflight = new Map>(); private readonly CACHE_TTL = 30_000; // 30 seconds + private readonly deps: HoverProviderDeps; constructor( private workspaceRoot: string, private symbolIndex: SymbolIndex | null, private sharedCache: RelationshipCache, - ) {} + deps?: Partial, + ) { + this.deps = { + ...defaultDeps, + ...(deps || {}), + }; + } async provideHover( document: vscode.TextDocument, @@ -81,8 +103,8 @@ export class KibiHoverProvider implements vscode.HoverProvider { const entities = await this.fetchEntityDetails(relationships, token); if (token.isCancellationRequested) return null; - // Build hover markdown using helper function - const markdown = buildHoverMarkdown( + // Build hover markdown using injected dependency + const markdown = this.deps.buildMarkdown( { id: symbolAtPosition.id, title: symbolAtPosition.title, @@ -135,7 +157,7 @@ export class KibiHoverProvider implements vscode.HoverProvider { symbolId: string, ): Promise> { try { - const output = execSync( + const output = this.deps.execCli( `bun run packages/cli/bin/kibi query --relationships ${symbolId} --format json`, { cwd: this.workspaceRoot, @@ -231,7 +253,7 @@ export class KibiHoverProvider implements vscode.HoverProvider { const entityType = typeMap[typePrefix]; if (!entityType) return null; - const output = execSync( + const output = this.deps.execCli( `bun run packages/cli/bin/kibi query ${entityType} --id ${entityId} --format json`, { cwd: this.workspaceRoot, diff --git a/packages/vscode/tests/codeActionProvider.test.ts b/packages/vscode/tests/codeActionProvider.test.ts new file mode 100644 index 0000000..2a8c4e8 --- /dev/null +++ b/packages/vscode/tests/codeActionProvider.test.ts @@ -0,0 +1,519 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getVscodeMockModule, resetVscodeMock } from "./shared/vscode-mock"; + +class MockCodeAction { + command?: { + command: string; + title: string; + arguments: unknown[]; + }; + + constructor( + public title: string, + public kind: unknown, + ) {} +} + +class MockPosition { + constructor( + public line: number, + public character: number, + ) {} +} + +class MockRange { + start: MockPosition; + end: MockPosition; + + constructor(start: MockPosition, end: MockPosition) { + this.start = start; + this.end = end; + } +} + +class MockSelection extends MockRange {} + +class MockRelativePattern { + constructor( + public base: unknown, + public pattern: string, + ) {} +} + +class MockFileSystemWatcher { + changeListeners: Array<() => void> = []; + createListeners: Array<() => void> = []; + deleteListeners: Array<() => void> = []; + + constructor(public pattern: unknown) {} + + onDidChange(listener: () => void) { + this.changeListeners.push(listener); + } + + onDidCreate(listener: () => void) { + this.createListeners.push(listener); + } + + onDidDelete(listener: () => void) { + this.deleteListeners.push(listener); + } + + emitChange() { + for (const listener of this.changeListeners) listener(); + } + + emitCreate() { + for (const listener of this.createListeners) listener(); + } + + emitDelete() { + for (const listener of this.deleteListeners) listener(); + } + + dispose() {} +} + +const createdWatchers: MockFileSystemWatcher[] = []; +const infoMessages: string[] = []; +const quickPickCalls: Array<{ items: unknown[]; options: unknown }> = []; +const openTextDocumentCalls: string[] = []; +const showTextDocumentCalls: Array<{ lineCount: number }> = []; +const revealCalls: Array<{ startLine: number; revealType: unknown }> = []; + +let quickPickResult: { label: string; detail?: string } | undefined; +let openedDocs = new Map< + string, + { uri: { fsPath: string }; lineCount: number } +>(); +let lastEditor: { + selection: MockSelection | undefined; + revealRange: (range: MockRange, revealType: unknown) => void; +} | null = null; + +const mockWorkspace = { + createFileSystemWatcher: (pattern: unknown) => { + const watcher = new MockFileSystemWatcher(pattern); + createdWatchers.push(watcher); + return watcher; + }, + openTextDocument: async (uri: { fsPath: string }) => { + openTextDocumentCalls.push(uri.fsPath); + return openedDocs.get(uri.fsPath) ?? { uri, lineCount: 1 }; + }, +}; + +const mockWindow = { + showInformationMessage: (message: string) => { + infoMessages.push(message); + }, + showQuickPick: async (items: unknown[], options: unknown) => { + quickPickCalls.push({ items, options }); + return quickPickResult; + }, + showTextDocument: async (doc: { lineCount: number }) => { + showTextDocumentCalls.push({ lineCount: doc.lineCount }); + lastEditor = { + selection: undefined, + revealRange: (range: MockRange, revealType: unknown) => { + revealCalls.push({ startLine: range.start.line, revealType }); + }, + }; + return lastEditor; + }, +}; + +function configureVscodeMock() { + resetVscodeMock({ + CodeAction: MockCodeAction, + Position: MockPosition, + Range: MockRange, + RelativePattern: MockRelativePattern, + Selection: MockSelection, + TextEditorRevealType: { InCenter: "in-center" }, + Uri: { file: (p: string) => ({ fsPath: p, path: p, scheme: "file" }) }, + window: mockWindow, + workspace: mockWorkspace, + }); +} + +configureVscodeMock(); + +mock.module("vscode", () => getVscodeMockModule()); + +const { KibiCodeActionProvider, browseLinkedEntities, openFileAtLine } = + await import("../src/codeActionProvider"); + +function writeSymbolsManifest( + dir: string, + symbols: Array>, + fileName = "symbols.yaml", +) { + const manifestPath = path.join(dir, fileName); + const lines: string[] = ["symbols:"]; + for (const symbol of symbols) { + lines.push(` - id: ${String(symbol.id ?? "")}`); + lines.push(` title: ${String(symbol.title ?? "")}`); + if (symbol.sourceFile) + lines.push(` sourceFile: ${String(symbol.sourceFile)}`); + if (typeof symbol.sourceLine === "number") { + lines.push(` sourceLine: ${symbol.sourceLine}`); + } + lines.push(" links:"); + for (const link of (Array.isArray(symbol.links) + ? symbol.links + : []) as string[]) { + lines.push(` - ${link}`); + } + } + fs.writeFileSync(manifestPath, `${lines.join("\n")}\n`, "utf8"); + return manifestPath; +} + +function makeDocument(filePath: string, word = "") { + return { + uri: { fsPath: filePath }, + getWordRangeAtPosition: () => + word + ? new MockRange( + new MockPosition(0, 0), + new MockPosition(0, word.length), + ) + : undefined, + getText: () => word, + } as never; +} + +function makeContext(): { subscriptions: unknown[] } { + return { subscriptions: [] }; +} + +describe("KibiCodeActionProvider", () => { + let tmpDir: string; + let testFile: string; + + beforeEach(() => { + configureVscodeMock(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-code-actions-")); + testFile = path.join(tmpDir, "src", "feature.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "export function feature() {}\n", "utf8"); + createdWatchers.length = 0; + infoMessages.length = 0; + quickPickCalls.length = 0; + openTextDocumentCalls.length = 0; + showTextDocumentCalls.length = 0; + revealCalls.length = 0; + quickPickResult = undefined; + openedDocs = new Map(); + lastEditor = null; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns no actions when manifest is missing", () => { + const provider = new KibiCodeActionProvider(tmpDir); + + expect( + provider.provideCodeActions( + makeDocument(testFile, "feature"), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ), + ).toEqual([]); + }); + + test("returns deduplicated actions for file and title matches", () => { + writeSymbolsManifest(tmpDir, [ + { + id: "SYM-001", + title: "feature", + sourceFile: "src/feature.ts", + sourceLine: 4, + links: ["REQ-001"], + }, + { + id: "SYM-002", + title: "feature", + sourceFile: "src/other.ts", + sourceLine: 8, + links: ["REQ-002"], + }, + ]); + + const provider = new KibiCodeActionProvider(tmpDir); + const actions = provider.provideCodeActions( + makeDocument(testFile, "feature"), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ); + + expect(actions).toHaveLength(2); + expect(actions.map((action) => action.title)).toEqual([ + 'Kibi: Browse linked entities for "feature"', + 'Kibi: Browse linked entities for "feature"', + ]); + expect(actions[0]?.command).toEqual({ + command: "kibi.browseLinkedEntities", + title: "Browse linked entities", + arguments: [ + "SYM-001", + ["REQ-001"], + path.join(tmpDir, "src", "feature.ts"), + 4, + ], + }); + expect(actions[1]?.command).toEqual({ + command: "kibi.browseLinkedEntities", + title: "Browse linked entities", + arguments: [ + "SYM-002", + ["REQ-002"], + path.join(tmpDir, "src", "other.ts"), + 8, + ], + }); + }); + + test("returns no actions when no symbol matches and no word is found", () => { + writeSymbolsManifest(tmpDir, [ + { + id: "SYM-001", + title: "different", + sourceFile: "src/other.ts", + sourceLine: 3, + links: [], + }, + ]); + + const provider = new KibiCodeActionProvider(tmpDir); + + expect( + provider.provideCodeActions( + makeDocument(testFile), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ), + ).toEqual([]); + }); + + test("watchManifest rebuilds on create and change, and clears on delete", () => { + writeSymbolsManifest(tmpDir, [ + { + id: "SYM-001", + title: "feature", + sourceFile: "src/feature.ts", + sourceLine: 1, + links: [], + }, + ]); + + const provider = new KibiCodeActionProvider(tmpDir); + const context = makeContext(); + provider.watchManifest(context as never); + + expect(createdWatchers).toHaveLength(1); + expect(context.subscriptions).toHaveLength(1); + expect( + provider.provideCodeActions( + makeDocument(testFile, "feature"), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ), + ).toHaveLength(1); + + fs.unlinkSync(path.join(tmpDir, "symbols.yaml")); + createdWatchers[0]?.emitDelete(); + expect( + provider.provideCodeActions( + makeDocument(testFile, "feature"), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ), + ).toEqual([]); + + writeSymbolsManifest( + tmpDir, + [ + { + id: "SYM-002", + title: "created", + sourceFile: "src/feature.ts", + sourceLine: 2, + links: ["REQ-123"], + }, + ], + "symbols.yml", + ); + createdWatchers[0]?.emitCreate(); + let actions = provider.provideCodeActions( + makeDocument(testFile, "created"), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ); + expect(actions).toHaveLength(1); + expect(actions[0]?.command?.arguments).toEqual([ + "SYM-002", + ["REQ-123"], + path.join(tmpDir, "src", "feature.ts"), + 2, + ]); + + writeSymbolsManifest( + tmpDir, + [ + { + id: "SYM-003", + title: "changed", + sourceFile: "src/feature.ts", + sourceLine: 3, + links: ["REQ-456"], + }, + ], + "symbols.yml", + ); + createdWatchers[0]?.emitChange(); + actions = provider.provideCodeActions( + makeDocument(testFile, "changed"), + new MockRange(new MockPosition(0, 0), new MockPosition(0, 0)) as never, + ); + expect(actions).toHaveLength(1); + expect(actions[0]?.command?.arguments).toEqual([ + "SYM-003", + ["REQ-456"], + path.join(tmpDir, "src", "feature.ts"), + 3, + ]); + }); +}); + +describe("browseLinkedEntities", () => { + beforeEach(() => { + infoMessages.length = 0; + quickPickCalls.length = 0; + openTextDocumentCalls.length = 0; + showTextDocumentCalls.length = 0; + revealCalls.length = 0; + quickPickResult = undefined; + openedDocs = new Map(); + lastEditor = null; + }); + + test("shows an information message when no linked entities exist", async () => { + await browseLinkedEntities("SYM-001", [], "/workspace", () => undefined); + + expect(infoMessages).toEqual([ + 'No linked entities found for symbol "SYM-001".', + ]); + expect(quickPickCalls).toHaveLength(0); + }); + + test("returns early when quick pick selection is cancelled", async () => { + await browseLinkedEntities( + "SYM-001", + [ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + { type: "implements", from: "SYM-001", to: "REQ-001" }, + ], + "/workspace", + (id) => + id === "REQ-001" + ? { localPath: "/workspace/req.md", line: 7 } + : undefined, + ); + + expect(quickPickCalls).toHaveLength(1); + expect(quickPickCalls[0]?.items).toEqual([ + { + label: "REQ-001", + description: "req.md", + detail: "/workspace/req.md", + }, + ]); + expect(openTextDocumentCalls).toEqual([]); + }); + + test("opens the navigation target line when a linked entity is selected", async () => { + const targetPath = "/workspace/requirements/REQ-001.md"; + quickPickResult = { label: "REQ-001", detail: targetPath }; + openedDocs.set(targetPath, { uri: { fsPath: targetPath }, lineCount: 10 }); + + await browseLinkedEntities( + "SYM-001", + [{ type: "implements", from: "SYM-001", to: "REQ-001" }], + "/workspace", + () => ({ localPath: targetPath, line: 20 }), + ); + + expect(openTextDocumentCalls).toEqual([targetPath]); + expect(showTextDocumentCalls).toEqual([{ lineCount: 10 }]); + expect(lastEditor?.selection?.start.line).toBe(9); + expect(revealCalls).toEqual([{ startLine: 9, revealType: "in-center" }]); + }); + + test("falls back to quick pick detail when no navigation target is returned", async () => { + const detailPath = "/workspace/scenarios/SCEN-001.md"; + quickPickResult = { label: "SCEN-001", detail: detailPath }; + openedDocs.set(detailPath, { uri: { fsPath: detailPath }, lineCount: 4 }); + + await browseLinkedEntities( + "SYM-001", + [{ type: "specified_by", from: "SCEN-001", to: "SYM-001" }], + "/workspace", + () => undefined, + ); + + expect(openTextDocumentCalls).toEqual([detailPath]); + expect(revealCalls).toEqual([]); + }); + + test("shows an information message when the selected entity has no local file", async () => { + quickPickResult = { label: "REQ-404" }; + + await browseLinkedEntities( + "SYM-001", + [{ type: "implements", from: "SYM-001", to: "REQ-404" }], + "/workspace", + () => undefined, + ); + + expect(infoMessages).toEqual([ + 'Entity "REQ-404" has no local source file.', + ]); + expect(openTextDocumentCalls).toEqual([]); + }); +}); + +describe("openFileAtLine", () => { + beforeEach(() => { + openTextDocumentCalls.length = 0; + showTextDocumentCalls.length = 0; + revealCalls.length = 0; + openedDocs = new Map(); + lastEditor = null; + }); + + test("opens a document without changing selection when line is omitted or non-positive", async () => { + const filePath = "/workspace/file.ts"; + openedDocs.set(filePath, { uri: { fsPath: filePath }, lineCount: 5 }); + + await openFileAtLine(filePath); + expect(lastEditor?.selection).toBeUndefined(); + expect(revealCalls).toEqual([]); + + await openFileAtLine(filePath, 0); + expect(lastEditor?.selection).toBeUndefined(); + expect(revealCalls).toEqual([]); + }); +}); + +afterAll(() => { + mock.restore(); +}); diff --git a/packages/vscode/tests/codeLensProvider.test.ts b/packages/vscode/tests/codeLensProvider.test.ts new file mode 100644 index 0000000..4b4ab68 --- /dev/null +++ b/packages/vscode/tests/codeLensProvider.test.ts @@ -0,0 +1,944 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { buildIndex } from "../src/symbolIndex"; +import { getVscodeMockModule, resetVscodeMock } from "./shared/vscode-mock"; + +class MockEventEmitter { + listeners: Array<() => void> = []; + fireCount = 0; + + event = (listener?: () => void) => { + if (listener) this.listeners.push(listener); + return { dispose() {} }; + }; + + fire() { + this.fireCount++; + for (const listener of this.listeners) listener(); + } + + dispose() {} +} + +class MockRange { + start: { line: number; character: number }; + end: { line: number; character: number }; + + constructor( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + ) { + this.start = { line: startLine, character: startCharacter }; + this.end = { line: endLine, character: endCharacter }; + } +} + +class MockCodeLens { + command?: unknown; + + constructor( + public range: unknown, + command?: unknown, + ) { + this.command = command; + } +} + +class MockFileSystemWatcher { + changeListeners: Array<() => void> = []; + createListeners: Array<() => void> = []; + deleteListeners: Array<() => void> = []; + + constructor(public pattern: unknown) {} + + onDidChange(listener: () => void) { + this.changeListeners.push(listener); + } + + onDidCreate(listener: () => void) { + this.createListeners.push(listener); + } + + onDidDelete(listener: () => void) { + this.deleteListeners.push(listener); + } + + emitChange() { + for (const listener of this.changeListeners) listener(); + } + + emitCreate() { + for (const listener of this.createListeners) listener(); + } + + emitDelete() { + for (const listener of this.deleteListeners) listener(); + } + + dispose() {} +} + +const MockUri = { + file: (p: string) => ({ fsPath: p, path: p, scheme: "file" }), +}; +const MockRelativePattern = class { + constructor( + public base: unknown, + public pattern: string, + ) {} +}; + +const createdWatchers: MockFileSystemWatcher[] = []; +const mockWorkspace = { + createFileSystemWatcher: (pattern: unknown) => { + const watcher = new MockFileSystemWatcher(pattern); + createdWatchers.push(watcher); + return watcher; + }, +}; + +const MockTreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; +class MockThemeIcon { + constructor(public id: string) {} +} +class MockTreeItem { + constructor( + public label: string, + public collapsibleState: number, + ) {} + iconPath?: MockThemeIcon; + contextValue?: string; +} +const mockWindow = { showInformationMessage: () => {} }; + +function configureVscodeMock() { + resetVscodeMock({ + EventEmitter: MockEventEmitter, + Range: MockRange, + CodeLens: MockCodeLens, + Uri: MockUri, + RelativePattern: MockRelativePattern, + workspace: mockWorkspace, + TreeItemCollapsibleState: MockTreeItemCollapsibleState, + ThemeIcon: MockThemeIcon, + TreeItem: MockTreeItem, + window: mockWindow, + }); +} + +configureVscodeMock(); + +mock.module("vscode", () => getVscodeMockModule()); + +let mockQueryImpl: ( + symbolId: string, + workspaceRoot: string, +) => Array<{ type: string; from: string; to: string }> = () => []; + +mock.module("../src/symbolIndex", () => ({ + buildIndex, + queryRelationshipsViaCli: (symbolId: string, workspaceRoot: string) => + mockQueryImpl(symbolId, workspaceRoot), +})); + +const { KibiCodeLensProvider } = await import("../src/codeLensProvider"); +const { RelationshipCache } = await import("../src/relationshipCache"); + +function writeTestSymbols( + dir: string, + symbols: Array>, + fileName = "symbols.yaml", +): string { + const symbolsPath = path.join(dir, fileName); + const lines: string[] = ["symbols:"]; + for (const symbol of symbols) { + lines.push(` - id: ${String(symbol.id ?? "")}`); + lines.push(` title: ${String(symbol.title ?? "")}`); + if (symbol.sourceFile) { + lines.push(` sourceFile: ${String(symbol.sourceFile)}`); + } else if (symbol.source) { + lines.push(` source: ${String(symbol.source)}`); + } + if (typeof symbol.sourceLine === "number") { + lines.push(` sourceLine: ${symbol.sourceLine}`); + } + lines.push(" links:"); + const links = Array.isArray(symbol.links) + ? (symbol.links as unknown[]) + : []; + for (const link of links) { + lines.push(` - ${String(link)}`); + } + } + fs.writeFileSync(symbolsPath, `${lines.join("\n")}\n`, "utf8"); + return symbolsPath; +} + +function makeProvider( + workspaceRoot: string, + cache?: InstanceType, +): InstanceType { + return new KibiCodeLensProvider( + workspaceRoot, + cache ?? new RelationshipCache(), + ); +} + +function makeDoc(fsPath: string) { + return { uri: { fsPath, scheme: "file" } } as never; +} + +type MockExtensionContext = { subscriptions: unknown[] }; + +function makeContext(): MockExtensionContext { + return { subscriptions: [] }; +} + +function getRange(lens: InstanceType) { + return lens.range as MockRange; +} + +function getCommand(lens: InstanceType) { + return lens.command as { + command: string; + title: string; + arguments: unknown[]; + }; +} + +async function waitForDebounce() { + await new Promise((resolve) => setTimeout(resolve, 550)); +} + +const noCancel = { isCancellationRequested: false } as never; +const cancelledToken = { isCancellationRequested: true } as never; + +describe("KibiCodeLensProvider – provideCodeLenses", () => { + let tmpDir: string; + + beforeEach(() => { + configureVscodeMock(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-test-")); + mockQueryImpl = () => []; + createdWatchers.length = 0; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns CodeLens for symbols in the current file", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "export function myFunction() {}\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 16, + links: ["REQ-001"], + }, + { + id: "SYM-002", + title: "anotherFunction", + sourceFile: "src/main.ts", + sourceLine: 42, + links: [], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses).not.toBeNull(); + expect(lenses?.length).toBe(2); + }); + + test("returns null when request is cancelled before providing", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// code\n", "utf8"); + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 1, + links: [], + }, + ]); + + const provider = makeProvider(tmpDir); + expect( + provider.provideCodeLenses(makeDoc(testFile), cancelledToken), + ).toBeNull(); + }); + + test("CodeLens positions are 0-based (sourceLine=16 → line 15)", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// code\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 16, + links: [], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses?.length).toBe(1); + if (!lenses) throw new Error("lenses is null"); + expect( + getRange(lenses[0] as InstanceType).start.line, + ).toBe(15); + }); + + test("returns null for symbols in a different file", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + const otherFile = path.join(tmpDir, "src", "other.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + fs.writeFileSync(otherFile, "// other\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "otherFunction", + sourceFile: "src/other.ts", + sourceLine: 10, + links: [], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses).toBeNull(); + }); + + test("returns null when symbols.yaml does not exist", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses).toBeNull(); + }); + + test("internal helper function entry produces CodeLens at correct line", () => { + const testFile = path.join(tmpDir, "src", "linker.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// linker\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-INT-001", + title: "mergeStaticLinks", + sourceFile: "src/linker.ts", + sourceLine: 42, + links: ["REQ-010"], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses?.length).toBe(1); + if (!lenses) throw new Error("lenses is null"); + expect( + getRange(lenses[0] as InstanceType).start.line, + ).toBe(41); + }); + + test("class method entry produces CodeLens at correct line", () => { + const testFile = path.join(tmpDir, "src", "codeLensProvider.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// provider\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-METHOD-001", + title: "provideCodeLenses", + sourceFile: "src/codeLensProvider.ts", + sourceLine: 78, + links: ["REQ-vscode-codelens"], + }, + { + id: "SYM-METHOD-002", + title: "resolveCodeLens", + sourceFile: "src/codeLensProvider.ts", + sourceLine: 115, + links: ["REQ-vscode-codelens"], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses?.length).toBe(2); + if (!lenses) throw new Error("lenses is null"); + expect( + getRange(lenses[0] as InstanceType).start.line, + ).toBe(77); + expect( + getRange(lenses[1] as InstanceType).start.line, + ).toBe(114); + }); + + test("returns null when symbols.yaml is malformed", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + const symbolsPath = path.join(tmpDir, "symbols.yaml"); + fs.writeFileSync(symbolsPath, "symbols: [\n - id: SYM-001", "utf8"); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses).toBeNull(); + }); + + test("symbols without sourceLine get lens at line 0", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + links: [], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + + expect(lenses?.length).toBe(1); + if (!lenses) throw new Error("lenses is null"); + expect( + getRange(lenses[0] as InstanceType).start.line, + ).toBe(0); + }); +}); + +describe("KibiCodeLensProvider – resolveCodeLens", () => { + let tmpDir: string; + + beforeEach(() => { + configureVscodeMock(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-test-")); + mockQueryImpl = () => []; + createdWatchers.length = 0; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function setupAndResolve( + workspaceRoot: string, + symbolLinks: string[], + dynamicRels: Array<{ type: string; from: string; to: string }>, + ) { + const testFile = path.join(workspaceRoot, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + writeTestSymbols(workspaceRoot, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 16, + links: symbolLinks, + }, + ]); + + mockQueryImpl = () => dynamicRels; + + const provider = makeProvider(workspaceRoot); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + if (!lenses || lenses.length === 0) throw new Error("no lenses"); + + return { + provider, + lens: lenses[0] as InstanceType, + resolved: await provider.resolveCodeLens(lenses[0], noCancel), + }; + } + + test("resolveCodeLens populates command with kibi.browseLinkedEntities", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + ["REQ-001"], + [{ type: "implements", from: "SYM-001", to: "REQ-002" }], + ); + + expect(resolved).not.toBeNull(); + if (!resolved) throw new Error("resolved is null"); + expect( + getCommand(resolved as InstanceType).command, + ).toBe("kibi.browseLinkedEntities"); + }); + + test("returns null when resolving a lens with no metadata", async () => { + const provider = makeProvider(tmpDir); + const lens = new MockCodeLens(new MockRange(0, 0, 0, 0)) as never; + expect(await provider.resolveCodeLens(lens, noCancel)).toBeNull(); + }); + + test("command arguments[1] contains full relationship objects (not just IDs)", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + ["REQ-001"], + [{ type: "implements", from: "SYM-001", to: "REQ-002" }], + ); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + + expect(cmd.arguments[0]).toBe("SYM-001"); + expect(cmd.arguments[1]).toEqual([ + { type: "relates_to", from: "SYM-001", to: "REQ-001" }, + { type: "implements", from: "SYM-001", to: "REQ-002" }, + ]); + expect(String(cmd.arguments[2])).toContain("src/main.ts"); + expect(cmd.arguments[3]).toBe(16); + }); + + test("lens title shows emoji-categorized counts", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + ["REQ-001", "ADR-005"], + [{ type: "implements", from: "SYM-001", to: "REQ-003" }], + ); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + expect(cmd.title).toBe("📋 2 reqs • 📐 1 ADR"); + }); + + test("cancelled token returns null from resolveCodeLens", async () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 16, + links: [], + }, + ]); + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + if (!lenses || lenses.length === 0) throw new Error("no lenses"); + + const resolved = await provider.resolveCodeLens(lenses[0], cancelledToken); + expect(resolved).toBeNull(); + }); + + test("guards show as 'guarded by {flagName}' instead of counted", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + [], + [{ type: "guards", from: "FLAG-feature_new_checkout", to: "SYM-001" }], + ); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + expect(cmd.title).toBe("🚩 guarded by feature_new_checkout"); + }); + + test("title shows 'No linked entities' when no relationships", async () => { + const { resolved } = await setupAndResolve(tmpDir, [], []); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + expect(cmd.title).toBe("No linked entities"); + }); + + test("static links are merged with dynamic relationships (static first)", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + ["REQ-STATIC-001", "REQ-STATIC-002"], + [ + { type: "implements", from: "SYM-001", to: "REQ-DYNAMIC-001" }, + { type: "verified_by", from: "SYM-001", to: "TEST-001" }, + ], + ); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + + expect(cmd.arguments[1]).toEqual([ + { type: "relates_to", from: "SYM-001", to: "REQ-STATIC-001" }, + { type: "relates_to", from: "SYM-001", to: "REQ-STATIC-002" }, + { type: "implements", from: "SYM-001", to: "REQ-DYNAMIC-001" }, + { type: "verified_by", from: "SYM-001", to: "TEST-001" }, + ]); + }); + + test("duplicate static and dynamic tuples are deduplicated", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + ["REQ-001"], + [ + { type: "relates_to", from: "SYM-001", to: "REQ-001" }, + { type: "relates_to", from: "SYM-001", to: "REQ-001" }, + ], + ); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + expect(cmd.arguments[1]).toEqual([ + { type: "relates_to", from: "SYM-001", to: "REQ-001" }, + ]); + }); + + test("title shows all emoji categories when multiple entity types present", async () => { + const { resolved } = await setupAndResolve( + tmpDir, + ["REQ-001", "TEST-001"], + [ + { type: "implements", from: "SYM-001", to: "REQ-002" }, + { type: "constrained_by", from: "SYM-001", to: "ADR-001" }, + ], + ); + + if (!resolved) throw new Error("resolved is null"); + const cmd = getCommand(resolved as InstanceType); + expect(cmd.title).toBe("📋 2 reqs • ✓ 1 test • 📐 1 ADR"); + }); +}); + +describe("KibiCodeLensProvider – caching", () => { + let tmpDir: string; + + beforeEach(() => { + configureVscodeMock(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-test-")); + mockQueryImpl = () => []; + createdWatchers.length = 0; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("multiple resolves within TTL call queryRelationshipsViaCli only once", async () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 16, + links: [], + }, + ]); + + let callCount = 0; + mockQueryImpl = () => { + callCount++; + return [{ type: "implements", from: "SYM-001", to: "REQ-001" }]; + }; + + const provider = makeProvider(tmpDir); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + if (!lenses || lenses.length === 0) throw new Error("no lenses"); + + await provider.resolveCodeLens(lenses[0], noCancel); + expect(callCount).toBe(1); + + await provider.resolveCodeLens(lenses[0], noCancel); + expect(callCount).toBe(1); + }); + + test("after cache cleared, a new CLI call is made", async () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "src/main.ts", + sourceLine: 16, + links: [], + }, + ]); + + let callCount = 0; + mockQueryImpl = () => { + callCount++; + return [{ type: "implements", from: "SYM-001", to: "REQ-001" }]; + }; + + const cache = new RelationshipCache(); + const provider = new KibiCodeLensProvider(tmpDir, cache); + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + if (!lenses || lenses.length === 0) throw new Error("no lenses"); + + await provider.resolveCodeLens(lenses[0], noCancel); + expect(callCount).toBe(1); + + cache.clear(); + + await provider.resolveCodeLens(lenses[0], noCancel); + expect(callCount).toBe(2); + }); +}); + +describe("KibiCodeLensProvider – refresh and watchers", () => { + let tmpDir: string; + + beforeEach(() => { + configureVscodeMock(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-codelens-refresh-")); + mockQueryImpl = () => []; + createdWatchers.length = 0; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("refresh uses relative symbolsManifest from .kb/config.json", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + const altDir = path.join(tmpDir, "config"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + const provider = makeProvider(tmpDir); + expect(provider.provideCodeLenses(makeDoc(testFile), noCancel)).toBeNull(); + + fs.mkdirSync(path.join(tmpDir, ".kb"), { recursive: true }); + fs.mkdirSync(altDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, ".kb", "config.json"), + JSON.stringify({ symbolsManifest: "config/symbols.yaml" }), + "utf8", + ); + writeTestSymbols( + altDir, + [ + { + id: "SYM-001", + title: "main", + sourceFile: "src/main.ts", + sourceLine: 3, + links: [], + }, + ], + "symbols.yaml", + ); + + provider.refresh(); + + const lenses = provider.provideCodeLenses(makeDoc(testFile), noCancel); + expect(lenses?.length).toBe(1); + }); + + test("refresh uses absolute paths.symbols, clears cache, and emits change", async () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + const manifestDir = path.join(tmpDir, "absolute"); + const manifestPath = path.join(manifestDir, "symbols.yml"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, ".kb"), { recursive: true }); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + fs.writeFileSync( + path.join(tmpDir, ".kb", "config.json"), + JSON.stringify({ paths: { symbols: manifestPath } }), + "utf8", + ); + writeTestSymbols( + manifestDir, + [ + { + id: "SYM-ABS-001", + title: "main", + sourceFile: "src/main.ts", + sourceLine: 5, + links: [], + }, + ], + "symbols.yml", + ); + + const cache = new RelationshipCache(); + cache.set("codelens:rel:SYM-ABS-001", { data: [], timestamp: Date.now() }); + const provider = makeProvider(tmpDir, cache); + let fireCount = 0; + provider.onDidChangeCodeLenses(() => { + fireCount++; + }); + + provider.refresh(); + + expect(cache.get("codelens:rel:SYM-ABS-001")).toBeUndefined(); + expect(fireCount).toBe(1); + expect( + provider.provideCodeLenses(makeDoc(testFile), noCancel)?.length, + ).toBe(1); + }); + + test("refresh ignores malformed config and falls back to symbols.yml", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, ".kb"), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + fs.writeFileSync(path.join(tmpDir, ".kb", "config.json"), "{", "utf8"); + writeTestSymbols( + tmpDir, + [ + { + id: "SYM-YML-001", + title: "main", + sourceFile: "src/main.ts", + sourceLine: 7, + links: [], + }, + ], + "symbols.yml", + ); + + const provider = makeProvider(tmpDir); + provider.refresh(); + + expect( + provider.provideCodeLenses(makeDoc(testFile), noCancel)?.length, + ).toBe(1); + }); + + test("refresh falls back to default symbols.yaml path when no manifest exists", () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + const cache = new RelationshipCache(); + cache.set("orphan", { data: [], timestamp: Date.now() }); + const provider = makeProvider(tmpDir, cache); + let fireCount = 0; + provider.onDidChangeCodeLenses(() => { + fireCount++; + }); + + provider.refresh(); + + expect(cache.get("orphan")).toBeUndefined(); + expect(fireCount).toBe(1); + expect(provider.provideCodeLenses(makeDoc(testFile), noCancel)).toBeNull(); + }); + + test("watchSources registers watchers and manifest changes debounce refresh", async () => { + const testFile = path.join(tmpDir, "src", "main.ts"); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, "// main\n", "utf8"); + + const provider = makeProvider(tmpDir); + let fireCount = 0; + provider.onDidChangeCodeLenses(() => { + fireCount++; + }); + + const context = makeContext(); + provider.watchSources(context as never); + + expect(createdWatchers.length).toBe(2); + expect(context.subscriptions.length).toBe(2); + + writeTestSymbols(tmpDir, [ + { + id: "SYM-001", + title: "main", + sourceFile: "src/main.ts", + sourceLine: 2, + links: [], + }, + ]); + + createdWatchers[0]?.emitChange(); + createdWatchers[0]?.emitChange(); + await waitForDebounce(); + + expect(fireCount).toBe(1); + expect( + provider.provideCodeLenses(makeDoc(testFile), noCancel)?.length, + ).toBe(1); + }); + + test("watchSources clears relationship cache on KB watcher events", async () => { + const cache = new RelationshipCache(); + cache.set("codelens:rel:SYM-001", { data: [], timestamp: Date.now() }); + const provider = makeProvider(tmpDir, cache); + let fireCount = 0; + provider.onDidChangeCodeLenses(() => { + fireCount++; + }); + + provider.watchSources(makeContext() as never); + createdWatchers[1]?.emitCreate(); + await waitForDebounce(); + + expect(cache.get("codelens:rel:SYM-001")).toBeUndefined(); + expect(fireCount).toBe(1); + }); + + test("debounce helper only invokes the latest call", async () => { + const provider = makeProvider(tmpDir); + const calls: string[] = []; + const withDebounce = provider as unknown as { + debounce: ( + fn: (value: string) => void, + delay: number, + ) => (value: string) => void; + }; + const debounced = withDebounce.debounce((value: string) => { + calls.push(value); + }, 20); + + debounced("first"); + debounced("second"); + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(calls).toEqual(["second"]); + }); +}); + +afterAll(() => { + mock.restore(); +}); diff --git a/packages/vscode/tests/hoverProvider.test.ts b/packages/vscode/tests/hoverProvider.test.ts new file mode 100644 index 0000000..789c4e7 --- /dev/null +++ b/packages/vscode/tests/hoverProvider.test.ts @@ -0,0 +1,572 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +// Import real implementations BEFORE mock.module intercepts them +const { buildHoverMarkdown: realBuildHoverMarkdown } = await import( + "../src/helpers?real" +); +import { getVscodeMockModule, resetVscodeMock } from "./shared/vscode-mock"; + +type MockMarkdownString = { value: string; isTrusted?: boolean }; +type MockHover = { contents: MockMarkdownString }; +type TestToken = { isCancellationRequested: boolean }; +type EntityDetails = { + id: string; + type: string; + title: string; + status: string; + tags: string[]; +}; + +class MockMarkdownStringImpl { + isTrusted?: boolean; + + constructor(public value: string) {} +} + +class MockHoverImpl { + constructor(public contents: MockMarkdownString) {} +} + +// Tests will inject per-test fakes via the provider constructor deps seam. + +function configureVscodeMock() { + resetVscodeMock({ + MarkdownString: MockMarkdownStringImpl, + Hover: MockHoverImpl, + }); +} + +configureVscodeMock(); + +mock.module("vscode", () => getVscodeMockModule()); + +// Per-test injected fakes +let fakeExecCli: (command: string, options?: Record) => string; +let fakeBuildHoverMarkdown: (...args: any[]) => string; + +const { KibiHoverProvider } = await import("../src/hoverProvider"); +const { RelationshipCache } = await import("../src/relationshipCache"); + +type MockSymbol = { + id: string; + title: string; + sourceFile?: string; + sourceLine?: number; +}; + +type MockRelationship = { type: string; from: string; to: string }; + +type HoverProviderInternal = { + symbolIndex: { byFile: Map } | null; + fetchRelationships(symbolId: string): Promise; + queryRelationshipsViaCli( + symbolId: string, + ): Promise; + fetchEntityDetails( + relationships: MockRelationship[], + token: TestToken, + ): Promise; + fetchEntityById(entityId: string): Promise; + queryEntityViaCli(entityId: string): Promise; + entityDetailsCache: Map< + string, + { data: EntityDetails | null; timestamp: number } + >; + entityInflight: Map>; +}; + +function makeProvider(symbols?: MockSymbol[], depsOverrides?: any) { + const filePath = "/workspace/src/example.ts"; + const symbolIndex = symbols + ? { + byFile: new Map([[filePath, symbols]]), + } + : null; + + const finalDeps = { + execCli: (cmd: string, opts?: Record) => + fakeExecCli(cmd, opts), + buildMarkdown: (...args: any[]) => fakeBuildHoverMarkdown(...args), + ...(depsOverrides || {}), + }; + + return { + filePath, + cache: new RelationshipCache(), + provider: new KibiHoverProvider( + "/workspace", + symbolIndex as never, + new RelationshipCache(), + finalDeps, + ), + }; +} + +function makeProviderWithCache( + symbols: MockSymbol[] = [], + depsOverrides?: any, +) { + const filePath = "/workspace/src/example.ts"; + const symbolIndex = { + byFile: new Map([[filePath, symbols]]), + }; + const cache = new RelationshipCache(); + + const finalDeps = { + execCli: (cmd: string, opts?: Record) => + fakeExecCli(cmd, opts), + buildMarkdown: (...args: any[]) => fakeBuildHoverMarkdown(...args), + ...(depsOverrides || {}), + }; + + return { + filePath, + cache, + provider: new KibiHoverProvider( + "/workspace", + symbolIndex as never, + cache, + finalDeps, + ), + }; +} + +function makeDocument(filePath: string) { + return { + uri: { fsPath: filePath }, + }; +} + +function makePosition(line: number, character = 0) { + return { line, character }; +} + +function makeToken(cancelled = false) { + return { isCancellationRequested: cancelled }; +} + +describe("KibiHoverProvider", () => { + beforeEach(() => { + configureVscodeMock(); + // reset per-test injected fakes + fakeExecCli = (_cmd: string) => "[]"; + fakeBuildHoverMarkdown = realBuildHoverMarkdown as any; + }); + afterEach(() => { + fakeBuildHoverMarkdown = realBuildHoverMarkdown as any; + }); + + test("provideHover returns null for early exit cases", async () => { + const noIndex = makeProvider(); + expect( + await noIndex.provider.provideHover( + makeDocument(noIndex.filePath) as never, + makePosition(0) as never, + makeToken(true) as never, + ), + ).toBeNull(); + + expect( + await noIndex.provider.provideHover( + makeDocument(noIndex.filePath) as never, + makePosition(0) as never, + makeToken(false) as never, + ), + ).toBeNull(); + + const noSymbols = makeProviderWithCache([]); + expect( + await noSymbols.provider.provideHover( + makeDocument(noSymbols.filePath) as never, + makePosition(0) as never, + makeToken(false) as never, + ), + ).toBeNull(); + + const wrongLine = makeProviderWithCache([ + { + id: "SYM-001", + title: "symbol", + sourceFile: noSymbols.filePath, + sourceLine: 5, + }, + ]); + expect( + await wrongLine.provider.provideHover( + makeDocument(wrongLine.filePath) as never, + makePosition(0) as never, + makeToken(false) as never, + ), + ).toBeNull(); + }); + + test("provideHover returns null when relationships are absent or cancellation happens after async steps", async () => { + const filePath = "/workspace/src/example.ts"; + const { provider } = makeProviderWithCache([ + { + id: "SYM-001", + title: "symbol", + sourceFile: filePath, + sourceLine: 1, + }, + ]); + const internal = provider as unknown as HoverProviderInternal; + + internal.fetchRelationships = mock(async () => []); + expect( + await provider.provideHover( + makeDocument(filePath) as never, + makePosition(0) as never, + makeToken(false) as never, + ), + ).toBeNull(); + + internal.fetchRelationships = mock(async () => [ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + ]); + const afterRelationshipsToken = makeToken(false); + internal.fetchEntityDetails = mock(async () => { + afterRelationshipsToken.isCancellationRequested = true; + return []; + }); + expect( + await provider.provideHover( + makeDocument(filePath) as never, + makePosition(0) as never, + afterRelationshipsToken as never, + ), + ).toBeNull(); + + internal.fetchRelationships = mock(async () => { + afterRelationshipsToken.isCancellationRequested = true; + return [{ type: "implements", from: "SYM-001", to: "REQ-001" }]; + }); + internal.fetchEntityDetails = mock(async () => []); + afterRelationshipsToken.isCancellationRequested = false; + expect( + await provider.provideHover( + makeDocument(filePath) as never, + makePosition(0) as never, + afterRelationshipsToken as never, + ), + ).toBeNull(); + }); + + test("provideHover builds trusted hover markdown for the symbol and fetched entities", async () => { + const { provider, filePath } = makeProviderWithCache([ + { + id: "SYM-001", + title: "myFunction", + sourceFile: "/workspace/src/example.ts", + sourceLine: 1, + }, + ]); + const entities = [ + { + id: "REQ-001", + type: "req", + title: "Requirement", + status: "open", + tags: ["core"], + }, + ]; + const internal = provider as unknown as HoverProviderInternal; + + internal.fetchRelationships = mock(async () => [ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + ]); + internal.fetchEntityDetails = mock(async () => entities); + // swap in a per-test mock implementation for buildHoverMarkdown + fakeBuildHoverMarkdown = mock(() => "rendered markdown"); + + const hover = (await provider.provideHover( + makeDocument(filePath) as never, + makePosition(0) as never, + makeToken(false) as never, + )) as MockHover | null; + + expect(hover).not.toBeNull(); + if (!hover) throw new Error("Expected hover"); + // buildHoverMarkdownMock is a function mock; ensure it was called with expected args + expect(fakeBuildHoverMarkdown).toHaveBeenCalledWith( + { + id: "SYM-001", + title: "myFunction", + file: "/workspace/src/example.ts", + line: 1, + }, + entities, + ); + expect(hover.contents.value).toBe("rendered markdown"); + expect(hover.contents.isTrusted).toBe(true); + }); + + test("fetchRelationships uses cache, inflight dedupe, stores truthy data, skips falsy data, and clears inflight on errors", async () => { + const { provider, cache } = makeProviderWithCache(); + const internal = provider as unknown as HoverProviderInternal; + + cache.set("rel:SYM-CACHED", { + data: [{ type: "implements", from: "SYM-CACHED", to: "REQ-001" }], + timestamp: Date.now(), + }); + expect(await internal.fetchRelationships("SYM-CACHED")).toEqual([ + { type: "implements", from: "SYM-CACHED", to: "REQ-001" }, + ]); + + cache.setInflight( + "rel:SYM-INFLIGHT", + Promise.resolve([ + { type: "covers", from: "SYM-INFLIGHT", to: "TEST-001" }, + ]), + ); + expect(await internal.fetchRelationships("SYM-INFLIGHT")).toEqual([ + { type: "covers", from: "SYM-INFLIGHT", to: "TEST-001" }, + ]); + + internal.queryRelationshipsViaCli = mock(async () => [ + { type: "implements", from: "SYM-OK", to: "REQ-OK" }, + ]); + expect(await internal.fetchRelationships("SYM-OK")).toEqual([ + { type: "implements", from: "SYM-OK", to: "REQ-OK" }, + ]); + expect(cache.get("rel:SYM-OK")?.data).toEqual([ + { type: "implements", from: "SYM-OK", to: "REQ-OK" }, + ]); + expect(cache.getInflight("rel:SYM-OK")).toBeUndefined(); + + internal.queryRelationshipsViaCli = mock(async () => null); + expect(await internal.fetchRelationships("SYM-NONE")).toBeNull(); + expect(cache.get("rel:SYM-NONE")).toBeUndefined(); + + internal.queryRelationshipsViaCli = mock(async () => { + throw new Error("boom"); + }); + expect(await internal.fetchRelationships("SYM-ERR")).toBeNull(); + expect(cache.getInflight("rel:SYM-ERR")).toBeUndefined(); + }); + + test("queryRelationshipsViaCli parses JSON and falls back to empty list on CLI failure", async () => { + const { provider } = makeProviderWithCache(); + const internal = provider as unknown as HoverProviderInternal; + fakeExecCli = mock( + () => '[{"type":"implements","from":"SYM-001","to":"REQ-001"}]', + ); + + expect(await internal.queryRelationshipsViaCli("SYM-001")).toEqual([ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + ]); + expect(fakeExecCli).toHaveBeenCalledWith( + "bun run packages/cli/bin/kibi query --relationships SYM-001 --format json", + { + cwd: "/workspace", + encoding: "utf8", + timeout: 10000, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + + fakeExecCli = mock(() => { + throw new Error("cli failed"); + }); + expect(await internal.queryRelationshipsViaCli("SYM-002")).toEqual([]); + }); + + test("fetchEntityDetails deduplicates ids, skips null entities, and respects cancellation", async () => { + const { provider } = makeProviderWithCache(); + const internal = provider as unknown as HoverProviderInternal; + const seen: string[] = []; + + internal.fetchEntityById = mock(async (id: string) => { + seen.push(id); + if (id === "REQ-001") { + return { + id, + type: "req", + title: "Requirement", + status: "open", + tags: [], + }; + } + if (id === "TEST-001") return null; + return { + id, + type: "symbol", + title: "Symbol", + status: "active", + tags: ["x"], + }; + }); + + expect( + await internal.fetchEntityDetails( + [ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + { type: "verified_by", from: "REQ-001", to: "TEST-001" }, + ], + makeToken(false), + ), + ).toEqual([ + { + id: "SYM-001", + type: "symbol", + title: "Symbol", + status: "active", + tags: ["x"], + }, + { + id: "REQ-001", + type: "req", + title: "Requirement", + status: "open", + tags: [], + }, + ]); + expect(seen).toEqual(["SYM-001", "REQ-001", "TEST-001"]); + + const cancellingToken = makeToken(false); + internal.fetchEntityById = mock(async () => { + cancellingToken.isCancellationRequested = true; + return { + id: "SYM-001", + type: "symbol", + title: "Symbol", + status: "active", + tags: [], + }; + }); + expect( + await internal.fetchEntityDetails( + [ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + { type: "verified_by", from: "REQ-001", to: "TEST-001" }, + ], + cancellingToken, + ), + ).toEqual([]); + }); + + test("fetchEntityById uses cache and inflight requests, caches results, and clears inflight after rejection", async () => { + const { provider } = makeProviderWithCache(); + const internal = provider as unknown as HoverProviderInternal; + const entityCache = internal.entityDetailsCache; + const inflight = internal.entityInflight; + + entityCache.set("entity:REQ-CACHED", { + data: { + id: "REQ-CACHED", + type: "req", + title: "Cached", + status: "open", + tags: [], + }, + timestamp: Date.now(), + }); + expect(await internal.fetchEntityById("REQ-CACHED")).toEqual({ + id: "REQ-CACHED", + type: "req", + title: "Cached", + status: "open", + tags: [], + }); + + inflight.set( + "entity:REQ-INFLIGHT", + Promise.resolve({ + id: "REQ-INFLIGHT", + type: "req", + title: "Inflight", + status: "open", + tags: [], + }), + ); + expect(await internal.fetchEntityById("REQ-INFLIGHT")).toEqual({ + id: "REQ-INFLIGHT", + type: "req", + title: "Inflight", + status: "open", + tags: [], + }); + + internal.queryEntityViaCli = mock(async () => ({ + id: "REQ-OK", + type: "req", + title: "Fresh", + status: "open", + tags: ["a"], + })); + expect(await internal.fetchEntityById("REQ-OK")).toEqual({ + id: "REQ-OK", + type: "req", + title: "Fresh", + status: "open", + tags: ["a"], + }); + expect(entityCache.get("entity:REQ-OK")?.data).toEqual({ + id: "REQ-OK", + type: "req", + title: "Fresh", + status: "open", + tags: ["a"], + }); + + internal.queryEntityViaCli = mock(async () => { + throw new Error("entity failure"); + }); + expect(await internal.fetchEntityById("REQ-ERR")).toBeNull(); + expect(inflight.get("entity:REQ-ERR")).toBeUndefined(); + }); + + test("queryEntityViaCli handles invalid ids, unsupported types, object and array payloads, empty payloads, defaults, and parse failures", async () => { + const { provider } = makeProviderWithCache(); + const internal = provider as unknown as HoverProviderInternal; + + expect(await internal.queryEntityViaCli("not-an-entity")).toBeNull(); + expect(await internal.queryEntityViaCli("FACT-001")).toBeNull(); + + fakeExecCli = mock(() => + JSON.stringify([ + { + id: "REQ-001", + title: "Requirement", + status: "closed", + tags: ["core"], + }, + ]), + ); + expect(await internal.queryEntityViaCli("REQ-001")).toEqual({ + id: "REQ-001", + type: "req", + title: "Requirement", + status: "closed", + tags: ["core"], + }); + + fakeExecCli = mock(() => JSON.stringify({})); + expect(await internal.queryEntityViaCli("TEST-001")).toEqual({ + id: "TEST-001", + type: "test", + title: "", + status: "unknown", + tags: [], + }); + + fakeExecCli = mock(() => "[]"); + expect(await internal.queryEntityViaCli("ADR-001")).toBeNull(); + + fakeExecCli = mock(() => { + throw new SyntaxError("bad json"); + }); + expect(await internal.queryEntityViaCli("REQ-ERR")).toBeNull(); + }); +}); + +afterAll(() => { + mock.restore(); +}); diff --git a/packages/vscode/tests/relationshipCache.test.ts b/packages/vscode/tests/relationshipCache.test.ts new file mode 100644 index 0000000..3684f42 --- /dev/null +++ b/packages/vscode/tests/relationshipCache.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { RelationshipCache } from "../src/relationshipCache"; + +describe("RelationshipCache", () => { + test("get() returns undefined for missing key", () => { + const cache = new RelationshipCache(); + expect(cache.get("nonexistent")).toBeUndefined(); + }); + + test("set() stores and get() retrieves a value", () => { + const cache = new RelationshipCache(); + const entry = { + data: [{ type: "implements", from: "SYM-001", to: "REQ-001" }], + timestamp: Date.now(), + }; + cache.set("SYM-001", entry); + expect(cache.get("SYM-001")).toEqual(entry); + }); + + test("set() overwrites existing key", () => { + const cache = new RelationshipCache(); + const first = { + data: [{ type: "implements", from: "SYM-001", to: "REQ-001" }], + timestamp: 1000, + }; + const second = { + data: [{ type: "relates_to", from: "SYM-001", to: "REQ-002" }], + timestamp: 2000, + }; + cache.set("SYM-001", first); + cache.set("SYM-001", second); + expect(cache.get("SYM-001")).toEqual(second); + }); + + test("clear() removes all cache and inflight entries", () => { + const cache = new RelationshipCache(); + cache.set("key1", { data: [], timestamp: 1 }); + cache.set("key2", { data: [], timestamp: 2 }); + cache.setInflight("key1", Promise.resolve([])); + + cache.clear(); + + expect(cache.get("key1")).toBeUndefined(); + expect(cache.get("key2")).toBeUndefined(); + expect(cache.getInflight("key1")).toBeUndefined(); + }); + + test("multiple set/get cycles work independently", () => { + const cache = new RelationshipCache(); + const entryA = { + data: [{ type: "implements", from: "SYM-A", to: "REQ-A" }], + timestamp: 100, + }; + const entryB = { + data: [{ type: "implements", from: "SYM-B", to: "REQ-B" }], + timestamp: 200, + }; + + cache.set("A", entryA); + cache.set("B", entryB); + + expect(cache.get("A")).toEqual(entryA); + expect(cache.get("B")).toEqual(entryB); + }); + + test("getInflight() returns undefined when no inflight request", () => { + const cache = new RelationshipCache(); + expect(cache.getInflight("missing")).toBeUndefined(); + }); + + test("setInflight() stores and getInflight() retrieves a promise", async () => { + const cache = new RelationshipCache(); + const promise = Promise.resolve([ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + ]); + cache.setInflight("SYM-001", promise); + + const retrieved = cache.getInflight("SYM-001"); + expect(retrieved).toBeDefined(); + const result = await retrieved!; + expect(result).toEqual([ + { type: "implements", from: "SYM-001", to: "REQ-001" }, + ]); + }); + + test("deleteInflight() removes an inflight entry", () => { + const cache = new RelationshipCache(); + cache.setInflight("key", Promise.resolve([])); + expect(cache.getInflight("key")).toBeDefined(); + + cache.deleteInflight("key"); + + expect(cache.getInflight("key")).toBeUndefined(); + }); + + test("deleteInflight() on nonexistent key does not throw", () => { + const cache = new RelationshipCache(); + expect(() => cache.deleteInflight("nope")).not.toThrow(); + }); + + test("getTTL() returns 30000", () => { + const cache = new RelationshipCache(); + expect(cache.getTTL()).toBe(30000); + }); +}); diff --git a/packages/vscode/tests/shared/vscode-mock.ts b/packages/vscode/tests/shared/vscode-mock.ts new file mode 100644 index 0000000..d68f32d --- /dev/null +++ b/packages/vscode/tests/shared/vscode-mock.ts @@ -0,0 +1,468 @@ +import { mock } from "bun:test"; + +type DisposableLike = { dispose: () => void }; +type PositionLike = { line: number; character: number }; +type MockNamespace = Record; + +type VscodeMockState = { + EventEmitter: new () => DefaultEventEmitter; + ThemeIcon: new (id: string) => DefaultThemeIcon; + TreeItem: new (label: string, collapsibleState: number) => DefaultTreeItem; + TreeItemCollapsibleState: { + None: number; + Collapsed: number; + Expanded: number; + }; + CodeActionKind: { Empty: string }; + TextEditorRevealType: { InCenter: string }; + CodeAction: new (title: string, kind: unknown) => DefaultCodeAction; + CodeLens: new (range: unknown, command?: unknown) => DefaultCodeLens; + Position: new (line: number, character: number) => DefaultPosition; + Range: new ( + startOrLine: number | PositionLike, + startCharacterOrEnd: number | PositionLike, + endLine?: number, + endCharacter?: number, + ) => DefaultRange; + Selection: new ( + startOrLine: number | PositionLike, + startCharacterOrEnd: number | PositionLike, + endLine?: number, + endCharacter?: number, + ) => DefaultSelection; + RelativePattern: new ( + base: unknown, + pattern: string, + ) => DefaultRelativePattern; + MarkdownString: new (value: string) => DefaultMarkdownString; + Hover: new (contents: unknown) => DefaultHover; + Uri: { + file: (filePath: string) => { + fsPath: string; + path: string; + scheme: string; + }; + }; + window: MockNamespace; + workspace: MockNamespace; + commands: MockNamespace; + languages: MockNamespace; +}; + +export type VscodeMockOverrides = { + EventEmitter?: unknown; + ThemeIcon?: unknown; + TreeItem?: unknown; + CodeAction?: unknown; + CodeLens?: unknown; + Position?: unknown; + Range?: unknown; + Selection?: unknown; + RelativePattern?: unknown; + MarkdownString?: unknown; + Hover?: unknown; + TreeItemCollapsibleState?: Partial< + VscodeMockState["TreeItemCollapsibleState"] + >; + CodeActionKind?: Partial; + TextEditorRevealType?: Partial; + Uri?: Partial; + window?: MockNamespace; + workspace?: MockNamespace; + commands?: MockNamespace; + languages?: MockNamespace; +}; + +// implements REQ-vscode-traceability +function createDisposable(): DisposableLike { + return { dispose() {} }; +} + +// implements REQ-vscode-traceability +function createOutputChannel() { + return { + appendLine: mock((_value: string) => {}), + dispose() {}, + }; +} + +// implements REQ-vscode-traceability +function createTextEditor() { + return { + selection: undefined as unknown, + revealRange: mock((_range: unknown, _revealType: unknown) => {}), + }; +} + +// implements REQ-vscode-traceability +function mergeNamespace( + base: T, + overrides?: MockNamespace, +): T { + return overrides ? ({ ...base, ...overrides } as T) : base; +} + +// implements REQ-vscode-traceability +function toPoint(value: PositionLike): PositionLike { + return { line: value.line, character: value.character }; +} + +// implements REQ-vscode-traceability +export class DefaultEventEmitter { + listeners: Array<(value: T) => void> = []; + fireCount = 0; + lastValue: T | undefined; + + event = (listener?: (value: T) => void) => { + if (listener) { + this.listeners.push(listener); + } + return createDisposable(); + }; + + fire(value: T) { + this.fireCount++; + this.lastValue = value; + for (const listener of this.listeners) { + listener(value); + } + } + + dispose() { + this.listeners = []; + } +} + +// implements REQ-vscode-traceability +export class DefaultThemeIcon { + constructor(public id: string) {} +} + +// implements REQ-vscode-traceability +export class DefaultTreeItem { + description?: string; + iconPath?: unknown; + contextValue?: string; + tooltip?: string; + command?: unknown; + resourceUri?: unknown; + + constructor( + public label: string, + public collapsibleState: number, + ) {} +} + +// implements REQ-vscode-traceability +export class DefaultCodeAction { + command?: unknown; + + constructor( + public title: string, + public kind: unknown, + ) {} +} + +// implements REQ-vscode-traceability +export class DefaultCodeLens { + constructor( + public range: unknown, + public command?: unknown, + ) {} +} + +// implements REQ-vscode-traceability +export class DefaultPosition { + constructor( + public line: number, + public character: number, + ) {} +} + +// implements REQ-vscode-traceability +export class DefaultRange { + start: PositionLike; + end: PositionLike; + + constructor( + startOrLine: number | PositionLike, + startCharacterOrEnd: number | PositionLike, + endLine?: number, + endCharacter?: number, + ) { + if ( + typeof startOrLine === "number" && + typeof startCharacterOrEnd === "number" && + typeof endLine === "number" && + typeof endCharacter === "number" + ) { + this.start = { line: startOrLine, character: startCharacterOrEnd }; + this.end = { line: endLine, character: endCharacter }; + return; + } + + this.start = toPoint(startOrLine as PositionLike); + this.end = toPoint(startCharacterOrEnd as PositionLike); + } +} + +// implements REQ-vscode-traceability +export class DefaultSelection extends DefaultRange {} + +// implements REQ-vscode-traceability +export class DefaultRelativePattern { + constructor( + public base: unknown, + public pattern: string, + ) {} +} + +// implements REQ-vscode-traceability +export class DefaultMarkdownString { + isTrusted?: boolean; + + constructor(public value: string) {} +} + +// implements REQ-vscode-traceability +export class DefaultHover { + constructor(public contents: unknown) {} +} + +// implements REQ-vscode-traceability +export class DefaultFileSystemWatcher { + changeListeners: Array<(...args: unknown[]) => void> = []; + createListeners: Array<(...args: unknown[]) => void> = []; + deleteListeners: Array<(...args: unknown[]) => void> = []; + + constructor(public pattern?: unknown) {} + + onDidChange(listener: (...args: unknown[]) => void) { + this.changeListeners.push(listener); + return createDisposable(); + } + + onDidCreate(listener: (...args: unknown[]) => void) { + this.createListeners.push(listener); + return createDisposable(); + } + + onDidDelete(listener: (...args: unknown[]) => void) { + this.deleteListeners.push(listener); + return createDisposable(); + } + + emitChange(...args: unknown[]) { + for (const listener of this.changeListeners) { + listener(...args); + } + } + + emitCreate(...args: unknown[]) { + for (const listener of this.createListeners) { + listener(...args); + } + } + + emitDelete(...args: unknown[]) { + for (const listener of this.deleteListeners) { + listener(...args); + } + } + + dispose() {} +} + +// implements REQ-vscode-traceability +function createDefaultState(): VscodeMockState { + return { + EventEmitter: DefaultEventEmitter, + ThemeIcon: DefaultThemeIcon, + TreeItem: DefaultTreeItem, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + CodeActionKind: { Empty: "empty-kind" }, + TextEditorRevealType: { InCenter: "in-center" }, + CodeAction: DefaultCodeAction, + CodeLens: DefaultCodeLens, + Position: DefaultPosition, + Range: DefaultRange, + Selection: DefaultSelection, + RelativePattern: DefaultRelativePattern, + MarkdownString: DefaultMarkdownString, + Hover: DefaultHover, + Uri: { + file: (filePath: string) => ({ + fsPath: filePath, + path: filePath, + scheme: "file", + }), + }, + window: { + showInformationMessage: mock(async (_message: string) => undefined), + showWarningMessage: mock(async (_message: string) => undefined), + showErrorMessage: mock(async (_message: string) => undefined), + showQuickPick: mock(async (_items: unknown[]) => undefined), + showTextDocument: mock(async (_doc: unknown) => createTextEditor()), + createOutputChannel: mock((_name: string) => createOutputChannel()), + createTreeView: mock((_id: string, _options: unknown) => + createDisposable(), + ), + }, + workspace: { + createFileSystemWatcher: mock( + (pattern: unknown) => new DefaultFileSystemWatcher(pattern), + ), + openTextDocument: mock(async (uri: unknown) => ({ uri, lineCount: 1 })), + getConfiguration: mock((_section?: string) => ({ + get: (_key: string, defaultValue?: T) => defaultValue as T, + })), + workspaceFolders: undefined, + onDidOpenTextDocument: mock((_listener: unknown) => createDisposable()), + }, + commands: { + registerCommand: mock((_command: string, _callback: unknown) => + createDisposable(), + ), + executeCommand: mock( + async (_command: string, ..._args: unknown[]) => undefined, + ), + }, + languages: { + registerCodeActionsProvider: mock( + (_selector: unknown, _provider: unknown, _metadata?: unknown) => + createDisposable(), + ), + registerCodeLensProvider: mock((_selector: unknown, _provider: unknown) => + createDisposable(), + ), + registerHoverProvider: mock((_selector: unknown, _provider: unknown) => + createDisposable(), + ), + }, + }; +} + +let state = createDefaultState(); + +const vscodeMockModule = { + get EventEmitter() { + return state.EventEmitter; + }, + get ThemeIcon() { + return state.ThemeIcon; + }, + get TreeItem() { + return state.TreeItem; + }, + get TreeItemCollapsibleState() { + return state.TreeItemCollapsibleState; + }, + get CodeActionKind() { + return state.CodeActionKind; + }, + get TextEditorRevealType() { + return state.TextEditorRevealType; + }, + get CodeAction() { + return state.CodeAction; + }, + get CodeLens() { + return state.CodeLens; + }, + get Position() { + return state.Position; + }, + get Range() { + return state.Range; + }, + get Selection() { + return state.Selection; + }, + get RelativePattern() { + return state.RelativePattern; + }, + get MarkdownString() { + return state.MarkdownString; + }, + get Hover() { + return state.Hover; + }, + get Uri() { + return state.Uri; + }, + get window() { + return state.window; + }, + get workspace() { + return state.workspace; + }, + get commands() { + return state.commands; + }, + get languages() { + return state.languages; + }, +}; + +// implements REQ-vscode-traceability +export function getVscodeMockModule() { + return vscodeMockModule; +} + +// implements REQ-vscode-traceability +export function resetVscodeMock(overrides: VscodeMockOverrides = {}): void { + const base = createDefaultState(); + state = { + ...base, + EventEmitter: + (overrides.EventEmitter as VscodeMockState["EventEmitter"] | undefined) ?? + base.EventEmitter, + ThemeIcon: + (overrides.ThemeIcon as VscodeMockState["ThemeIcon"] | undefined) ?? + base.ThemeIcon, + TreeItem: + (overrides.TreeItem as VscodeMockState["TreeItem"] | undefined) ?? + base.TreeItem, + CodeAction: + (overrides.CodeAction as VscodeMockState["CodeAction"] | undefined) ?? + base.CodeAction, + CodeLens: + (overrides.CodeLens as VscodeMockState["CodeLens"] | undefined) ?? + base.CodeLens, + Position: + (overrides.Position as VscodeMockState["Position"] | undefined) ?? + base.Position, + Range: + (overrides.Range as VscodeMockState["Range"] | undefined) ?? base.Range, + Selection: + (overrides.Selection as VscodeMockState["Selection"] | undefined) ?? + base.Selection, + RelativePattern: + (overrides.RelativePattern as + | VscodeMockState["RelativePattern"] + | undefined) ?? base.RelativePattern, + MarkdownString: + (overrides.MarkdownString as + | VscodeMockState["MarkdownString"] + | undefined) ?? base.MarkdownString, + Hover: + (overrides.Hover as VscodeMockState["Hover"] | undefined) ?? base.Hover, + TreeItemCollapsibleState: { + ...base.TreeItemCollapsibleState, + ...(overrides.TreeItemCollapsibleState ?? {}), + }, + CodeActionKind: { + ...base.CodeActionKind, + ...(overrides.CodeActionKind ?? {}), + }, + TextEditorRevealType: { + ...base.TextEditorRevealType, + ...(overrides.TextEditorRevealType ?? {}), + }, + Uri: mergeNamespace(base.Uri, overrides.Uri), + window: mergeNamespace(base.window, overrides.window), + workspace: mergeNamespace(base.workspace, overrides.workspace), + commands: mergeNamespace(base.commands, overrides.commands), + languages: mergeNamespace(base.languages, overrides.languages), + }; +} diff --git a/packages/vscode/tests/treeProvider.test.ts b/packages/vscode/tests/treeProvider.test.ts new file mode 100644 index 0000000..9493ea9 --- /dev/null +++ b/packages/vscode/tests/treeProvider.test.ts @@ -0,0 +1,607 @@ +import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getVscodeMockModule, resetVscodeMock } from "./shared/vscode-mock"; + +const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; + +class ThemeIcon { + constructor(public id: string) {} +} + +class TreeItem { + description?: string; + iconPath?: ThemeIcon; + contextValue?: string; + tooltip?: string; + command?: { + command: string; + title: string; + arguments: unknown[]; + }; + resourceUri?: { fsPath: string }; + + constructor( + public label: string, + public collapsibleState: number, + ) {} +} + +class EventEmitter { + fireCount = 0; + lastValue: T | undefined; + event = () => ({ dispose() {} }); + + fire(value: T) { + this.fireCount++; + this.lastValue = value; + } +} + +const window = { showInformationMessage: mock(() => {}) }; +const Uri = { + file: (filePath: string) => ({ + fsPath: filePath, + path: filePath, + scheme: "file", + }), +}; + +function configureVscodeMock() { + resetVscodeMock({ + TreeItemCollapsibleState, + ThemeIcon, + TreeItem, + EventEmitter, + window, + Uri, + }); +} + +configureVscodeMock(); + +mock.module("vscode", () => getVscodeMockModule()); + +const { KibiTreeDataProvider } = await import("../src/treeProvider"); + +let tmpDir: string; + +function writeJson(filePath: string, value: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function writeFile(filePath: string, content = "") { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function writeSymbolsManifest( + workspaceRoot: string, + symbols: Array<{ + id: string; + title: string; + sourceFile?: string; + sourceLine?: number; + links?: string[]; + }>, +) { + const lines = ["symbols:"]; + for (const symbol of symbols) { + lines.push(` - id: ${symbol.id}`); + lines.push(` title: ${symbol.title}`); + if (symbol.sourceFile) lines.push(` sourceFile: ${symbol.sourceFile}`); + if (typeof symbol.sourceLine === "number") { + lines.push(` sourceLine: ${symbol.sourceLine}`); + } + lines.push(" links:"); + for (const link of symbol.links ?? []) { + lines.push(` - ${link}`); + } + } + + writeFile( + path.join(workspaceRoot, "documentation", "symbols.yaml"), + `${lines.join("\n")}\n`, + ); +} + +function makeProvider( + workspaceRoot = tmpDir, + output?: { appendLine: (value: string) => void }, +) { + return new KibiTreeDataProvider(workspaceRoot, output as never); +} + +type RefreshInternals = { + loaded: boolean; + entities: Array<{ id: string }>; + relationships: Array<{ fromId: string }>; + symbolIndex: object | null; + documentationEntityDirs: object | null; + _onDidChangeTreeData: EventEmitter; +}; + +type ParseRdfInternals = { + parseRdf: (content: string) => Array<{ id: string; localPath?: string }>; + parseRdfRelationships: (content: string) => Array<{ + relType: string; + fromId: string; + toId: string; + }>; +}; + +type DocumentationDirsInternals = { + documentationEntityDirs: Record | null; + getDocumentationEntityDirs: () => Record; + resolveConfiguredPath: (configuredPath: string) => string; +}; + +type TypeInferenceInternals = { + inferEntityTypeFromId: (id: string) => string | undefined; + getDocumentationPathForEntity: ( + id: string, + type?: string, + ) => string | undefined; +}; + +type NavigationInternals = { + entities: Array<{ + id: string; + type: string; + title: string; + status: string; + tags: string; + source: string; + localPath?: string; + sourceLine?: number; + }>; + symbolIndex: { + byId: Map; + } | null; +}; + +type FallbackSymbolInternals = { + getFallbackSymbolEntity: (symbol: { + id: string; + title: string; + sourceFile?: string; + sourceLine?: number; + }) => { + source: string; + localPath?: string; + sourceLine?: number; + }; +}; + +type FrontmatterInternals = { + parseFrontmatter: (content: string) => Record; + normalizeTags: (tags: unknown) => string; + parseFrontmatterLinks: ( + fromId: string, + links: unknown, + ) => Array<{ relType: string; fromId: string; toId: string }>; +}; + +beforeEach(() => { + configureVscodeMock(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-tree-provider-")); + window.showInformationMessage.mockReset(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("refresh clears cached data and fires tree update", () => { + const provider = makeProvider(); + const internals = provider as unknown as RefreshInternals; + + internals.loaded = true; + internals.entities = [{ id: "REQ-001" }]; + internals.relationships = [{ fromId: "REQ-001" }]; + internals.symbolIndex = { byId: new Map() }; + internals.documentationEntityDirs = { req: "/tmp/docs" }; + + provider.refresh(); + + expect(internals.loaded).toBe(false); + expect(internals.entities).toEqual([]); + expect(internals.relationships).toEqual([]); + expect(internals.symbolIndex).toBeNull(); + expect(internals.documentationEntityDirs).toBeNull(); + expect(internals._onDidChangeTreeData.fireCount).toBe(1); +}); + +test("getTreeItem maps file-backed and relationship-backed nodes to commands", () => { + const provider = makeProvider(); + + const fileTreeItem = provider.getTreeItem({ + label: "REQ-001: Test", + description: "documentation/requirements/REQ-001.md", + iconPath: "list-ordered", + contextValue: "kibi-entity-req", + collapsibleState: TreeItemCollapsibleState.Collapsed, + tooltip: "tooltip", + localPath: "/tmp/REQ-001.md", + sourceLine: 4, + }); + + expect(fileTreeItem.description).toBe( + "documentation/requirements/REQ-001.md", + ); + expect((fileTreeItem.iconPath as ThemeIcon).id).toBe("list-ordered"); + expect(fileTreeItem.contextValue).toBe("kibi-entity-req"); + expect(fileTreeItem.tooltip).toBe("tooltip"); + expect(fileTreeItem.command).toEqual({ + command: "kibi.openEntity", + title: "Open Entity File", + arguments: ["/tmp/REQ-001.md", 4], + }); + expect(fileTreeItem.resourceUri).toMatchObject({ fsPath: "/tmp/REQ-001.md" }); + + const relationshipTreeItem = provider.getTreeItem({ + label: "→ relates to: REQ-002", + collapsibleState: TreeItemCollapsibleState.None, + targetId: "REQ-002", + }); + + expect(relationshipTreeItem.command).toEqual({ + command: "kibi.openEntityById", + title: "Open Related Entity", + arguments: ["REQ-002"], + }); +}); + +test("getChildren returns nested children directly and handles missing workspace", async () => { + const provider = makeProvider(""); + expect(await provider.getChildren()).toEqual([]); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "No workspace folder open", + ); + + const child = { + label: "child", + collapsibleState: TreeItemCollapsibleState.None, + }; + expect( + await makeProvider().getChildren({ + label: "parent", + collapsibleState: TreeItemCollapsibleState.Collapsed, + children: [child], + }), + ).toEqual([child]); +}); + +test("parseRdf resolves absolute, file URI, relative, windows, and invalid local sources", () => { + const provider = makeProvider(); + const internals = provider as unknown as ParseRdfInternals; + const absoluteFile = path.join(tmpDir, "absolute.md"); + const relativeFile = path.join( + tmpDir, + "documentation", + "requirements", + "REQ-REL.md", + ); + writeFile(absoluteFile, "absolute"); + writeFile(relativeFile, "relative"); + + const rdf = ` + + req + Absolute + + + ${absoluteFile} + + + req + File Uri + + + ${new URL(`file://${absoluteFile}`).toString()} + + + req + Bad Uri + + + file://% + + + req + Relative + + + documentation/requirements/REQ-REL.md + + + req + Windows + + + C:\\missing\\REQ-WIN.md + + + req + Remote + + + https://example.com/REQ-URL + + `; + + const entities = internals.parseRdf(rdf); + const byId = new Map( + entities.map((entity: { id: string; localPath?: string }) => [ + entity.id, + entity, + ]), + ); + + expect(byId.get("REQ-ABS")?.localPath).toBe(absoluteFile); + expect(byId.get("REQ-FILE")?.localPath).toBe(absoluteFile); + expect(byId.get("REQ-BAD-URI")?.localPath).toBeUndefined(); + expect(byId.get("REQ-REL")?.localPath).toBe(relativeFile); + expect(byId.get("REQ-WIN")?.localPath).toBeUndefined(); + expect(byId.get("REQ-URL")?.localPath).toBeUndefined(); +}); + +test("parseRdfRelationships extracts outgoing relationships from RDF blocks", () => { + const provider = makeProvider(); + const internals = provider as unknown as ParseRdfInternals; + + const rdf = ` + + + + + + + + + + + + + `; + + expect(internals.parseRdfRelationships(rdf)).toEqual([ + { relType: "depends_on", fromId: "REQ-001", toId: "REQ-002" }, + { relType: "specified_by", fromId: "REQ-001", toId: "SCEN-001" }, + { relType: "verified_by", fromId: "REQ-001", toId: "TEST-001" }, + { relType: "implements", fromId: "REQ-001", toId: "REQ-010" }, + { relType: "covered_by", fromId: "REQ-001", toId: "TEST-002" }, + { relType: "constrained_by", fromId: "REQ-001", toId: "ADR-001" }, + { relType: "guards", fromId: "REQ-001", toId: "FLAG-001" }, + { relType: "publishes", fromId: "REQ-001", toId: "EVT-001" }, + { relType: "consumes", fromId: "REQ-001", toId: "EVT-002" }, + { relType: "relates_to", fromId: "REQ-001", toId: "FACT-001" }, + ]); +}); + +test("frontmatter helpers normalize tags and links from YAML content", () => { + const provider = makeProvider(); + const internals = provider as unknown as FrontmatterInternals; + + expect( + internals.parseFrontmatter( + "---\nid: REQ-123\ntags:\n - alpha\n - 2\n---\nbody", + ), + ).toEqual({ + id: "REQ-123", + tags: ["alpha", 2], + }); + expect(internals.parseFrontmatter("not-frontmatter")).toEqual({}); + expect(internals.normalizeTags(["alpha", 2])).toBe("[alpha, 2]"); + expect(internals.normalizeTags("alpha")).toBe("alpha"); + expect(internals.normalizeTags({})).toBe(""); + expect( + internals.parseFrontmatterLinks("REQ-123", [ + "REQ-124", + { type: "verified_by", target: "TEST-123" }, + { to: "SCEN-123" }, + null, + { type: "depends_on" }, + ]), + ).toEqual([ + { relType: "relates_to", fromId: "REQ-123", toId: "REQ-124" }, + { relType: "verified_by", fromId: "REQ-123", toId: "TEST-123" }, + { relType: "relates_to", fromId: "REQ-123", toId: "SCEN-123" }, + ]); +}); + +test("documentation directory resolution honors config, fallback defaults, and caching", () => { + const provider = makeProvider(); + const internals = provider as unknown as DocumentationDirsInternals; + + writeFile(path.join(tmpDir, "docs", "requirements", ".gitkeep")); + writeFile(path.join(tmpDir, "documentation", "tests", ".gitkeep")); + writeJson(path.join(tmpDir, ".kb", "config.json"), { + paths: { requirements: "docs/requirements" }, + }); + + expect(internals.resolveConfiguredPath("docs/requirements")).toBe( + path.join(tmpDir, "docs", "requirements"), + ); + expect(internals.resolveConfiguredPath("/tmp/absolute-docs")).toBe( + "/tmp/absolute-docs", + ); + + const first = internals.getDocumentationEntityDirs(); + const second = internals.getDocumentationEntityDirs(); + expect(first).toBe(second); + expect(first.req).toBe(path.join(tmpDir, "docs", "requirements")); + expect(first.test).toBe(path.join(tmpDir, "documentation", "tests")); + + internals.documentationEntityDirs = null; + writeFile(path.join(tmpDir, ".kb", "config.json"), "{not-json"); + writeFile(path.join(tmpDir, "documentation", "requirements", ".gitkeep")); + + const fallback = internals.getDocumentationEntityDirs(); + expect(fallback.req).toBe(path.join(tmpDir, "documentation", "requirements")); +}); + +test("type inference and documentation path lookup cover prefixes and symbol exclusion", () => { + const provider = makeProvider(); + const internals = provider as unknown as TypeInferenceInternals; + const requirementPath = path.join( + tmpDir, + "documentation", + "requirements", + "REQ-777.md", + ); + writeFile(requirementPath, "---\nid: REQ-777\n---\n"); + + expect(internals.inferEntityTypeFromId("EVENT-001")).toBe("event"); + expect(internals.inferEntityTypeFromId("SYM-001")).toBe("symbol"); + expect(internals.inferEntityTypeFromId("UNKNOWN-001")).toBeUndefined(); + + expect(internals.getDocumentationPathForEntity("REQ-777")).toBe( + requirementPath, + ); + expect( + internals.getDocumentationPathForEntity("REQ-777", "symbol"), + ).toBeUndefined(); + expect( + internals.getDocumentationPathForEntity("REQ-404", "req"), + ).toBeUndefined(); +}); + +test("navigation helpers prefer entity paths, then symbol sources, then documentation files", () => { + const output = { appendLine: mock(() => {}) }; + const provider = makeProvider(tmpDir, output); + const internals = provider as unknown as NavigationInternals; + + const existingSymbolSource = path.join(tmpDir, "src", "symbol.ts"); + const docPath = path.join( + tmpDir, + "documentation", + "requirements", + "REQ-DOC.md", + ); + writeFile(existingSymbolSource, "export const symbol = 1;\n"); + writeFile(docPath, "---\nid: REQ-DOC\n---\n"); + + internals.entities = [ + { + id: "REQ-MISSING", + type: "req", + title: "Missing File", + status: "open", + tags: "", + source: "documentation/requirements/REQ-MISSING.md", + localPath: path.join( + tmpDir, + "documentation", + "requirements", + "REQ-MISSING.md", + ), + }, + { + id: "REQ-LOCAL", + type: "req", + title: "Local File", + status: "open", + tags: "", + source: "documentation/requirements/REQ-LOCAL.md", + localPath: docPath, + sourceLine: 9, + }, + ]; + internals.symbolIndex = { + byId: new Map([ + ["SYM-123", { sourceFile: existingSymbolSource, sourceLine: 7 }], + [ + "SYM-MISSING", + { sourceFile: path.join(tmpDir, "src", "missing.ts"), sourceLine: 2 }, + ], + ]), + }; + + expect(provider.getNavigationTargetForEntity("REQ-MISSING")).toBeUndefined(); + expect(output.appendLine).toHaveBeenCalledWith( + expect.stringContaining("REQ-MISSING has localPath"), + ); + + expect(provider.getNavigationTargetForEntity("REQ-LOCAL")).toEqual({ + localPath: docPath, + line: 9, + }); + expect(provider.getNavigationTargetForEntity("SYM-123")).toEqual({ + localPath: existingSymbolSource, + line: 7, + }); + expect(provider.getNavigationTargetForEntity("REQ-DOC")).toEqual({ + localPath: docPath, + }); + expect(provider.getNavigationTargetForEntity("SYM-MISSING")).toBeUndefined(); + expect(provider.getNavigationTargetForEntity("UNKNOWN")).toBeUndefined(); + expect(provider.getLocalPathForEntity("REQ-DOC")).toBe(docPath); + expect(provider.getLocalPathForEntity("UNKNOWN")).toBeUndefined(); +}); + +test("entity helpers and symbol fallbacks expose counts, lookup, and manifest-relative sources", async () => { + writeJson(path.join(tmpDir, ".kb", "config.json"), {}); + writeFile( + path.join(tmpDir, "documentation", "requirements", "REQ-001.md"), + "---\nid: REQ-001\n---\n", + ); + writeFile(path.join(tmpDir, "src", "one.ts"), "export const one = 1;\n"); + writeSymbolsManifest(tmpDir, [ + { + id: "SYM-001", + title: "one", + sourceFile: "src/one.ts", + sourceLine: 5, + links: ["REQ-001"], + }, + { + id: "SYM-002", + title: "two", + sourceFile: "src/missing.ts", + sourceLine: 8, + }, + ]); + + const provider = makeProvider(); + const internals = provider as unknown as FallbackSymbolInternals; + + await provider.getChildren(); + + expect(provider.getEntityCount("symbol")).toBe(2); + expect(provider.getEntityCount("req")).toBe(1); + expect(provider.getEntityById("REQ-001")?.title).toBe("REQ-001"); + expect(provider.getEntityById("UNKNOWN")).toBeUndefined(); + + expect( + internals.getFallbackSymbolEntity({ + id: "SYM-EXISTING", + title: "existing", + sourceFile: path.join(tmpDir, "src", "one.ts"), + sourceLine: 11, + }), + ).toMatchObject({ + source: "src/one.ts", + localPath: path.join(tmpDir, "src", "one.ts"), + sourceLine: 11, + }); + + expect( + internals.getFallbackSymbolEntity({ + id: "SYM-MANIFEST", + title: "manifest-only", + sourceFile: path.join(tmpDir, "src", "missing.ts"), + sourceLine: 3, + }), + ).toMatchObject({ + source: "src/missing.ts", + localPath: undefined, + sourceLine: 3, + }); +}); + +afterAll(() => { + mock.restore(); +});