diff --git a/plugin/dist/hook.mjs b/plugin/dist/hook.mjs index 3af6799..9a74635 100644 --- a/plugin/dist/hook.mjs +++ b/plugin/dist/hook.mjs @@ -5718,7 +5718,9 @@ function extractClaims(message, limits = CLAIM_LIMITS) { } const onlyFile = files.length > 0 && new Set(files.map((f) => f.path)).size === 1 ? files[0].path : void 0; if (onlyFile !== void 0) { - for (const ident of idents) if (ident.file === void 0) ident.file = onlyFile; + for (const ident of idents) { + if (ident.file === void 0 && CHANGE_VERB.test(ident.sentence)) ident.file = onlyFile; + } } return { files, idents, claims }; } @@ -5752,9 +5754,10 @@ function checkReferences(extracted, ctx) { if (!isProjectRelative(ref.file)) return { ...ref, verdict: "unlocated" }; const segment = ref.name.split(".").pop() ?? ref.name; const read2 = ctx.readFile(ref.file); - const inFile = read2.kind === "text" && read2.text.includes(segment); const inDiff = added.includes(segment); if (read2.kind === "refused") return { ...ref, verdict: "refused" }; + if (read2.kind !== "text") return { ...ref, verdict: inDiff ? "present" : "unlocated" }; + const inFile = read2.text.includes(segment); return inFile || inDiff ? { ...ref, verdict: "present" } : { ...ref, verdict: "fabricated" }; }); return { files, idents }; diff --git a/src/hooks/claims.ts b/src/hooks/claims.ts index d05c3cb..8c72915 100644 --- a/src/hooks/claims.ts +++ b/src/hooks/claims.ts @@ -135,10 +135,13 @@ export function extractClaims(message: string, limits: ExtractLimits = CLAIM_LIM } } - // An identifier with no file of its own borrows the message's only file. + // An identifier in a change sentence with no file of its own borrows the + // message's only file. Descriptive prose must not inherit an unrelated file. const onlyFile = files.length > 0 && new Set(files.map((f) => f.path)).size === 1 ? files[0]!.path : undefined; if (onlyFile !== undefined) { - for (const ident of idents) if (ident.file === undefined) ident.file = onlyFile; + for (const ident of idents) { + if (ident.file === undefined && CHANGE_VERB.test(ident.sentence)) ident.file = onlyFile; + } } return { files, idents, claims }; @@ -221,9 +224,10 @@ export function checkReferences( if (!isProjectRelative(ref.file)) return { ...ref, verdict: "unlocated" }; const segment = ref.name.split(".").pop() ?? ref.name; const read = ctx.readFile(ref.file); - const inFile = read.kind === "text" && (read as { text: string }).text.includes(segment); const inDiff = added.includes(segment); if (read.kind === "refused") return { ...ref, verdict: "refused" }; + if (read.kind !== "text") return { ...ref, verdict: inDiff ? "present" : "unlocated" }; + const inFile = (read as { text: string }).text.includes(segment); return inFile || inDiff ? { ...ref, verdict: "present" } : { ...ref, verdict: "fabricated" }; }); diff --git a/tests/hooks/claims.test.ts b/tests/hooks/claims.test.ts index bb04293..9525810 100644 --- a/tests/hooks/claims.test.ts +++ b/tests/hooks/claims.test.ts @@ -30,6 +30,19 @@ describe("extractClaims", () => { const out = extractClaims("Ran `npm` and touched `ab`."); expect(out.idents).toHaveLength(0); }); + + it("does not bind descriptive identifiers to an unrelated only file", () => { + const out = extractClaims( + "The plugin exposes `route_prompts`, `jev_evaluate`, and `jev_rank`. Updated CLAUDE.md with setup notes.", + ); + expect(out.idents).toHaveLength(3); + for (const ident of out.idents) expect(ident.file).toBeUndefined(); + }); + + it("still binds a changed identifier to the message's only file", () => { + const out = extractClaims("Added `retryWithBackoff` for transient failures. The implementation is in src/client.ts."); + expect(out.idents[0]?.file).toBe("src/client.ts"); + }); }); function context(overrides: Partial): Stage1Context { @@ -90,6 +103,24 @@ describe("checkReferences (stage 1)", () => { expect(idents[0]!.verdict).toBe("present"); }); + it("does not fabricate an identifier when its bound file cannot be read", () => { + const extracted = extractClaims("Added `retryWithBackoff` to src/client.ts."); + const stage1 = checkReferences( + extracted, + context({ + fileExists: () => "yes", + readFile: () => ({ kind: "missing" }), + diffText: "", + }), + ); + expect(stage1.idents[0]!.verdict).toBe("unlocated"); + + const result = stopClaimsPolicy(stage1, undefined, 0.85); + expect(result.block).toBe(false); + expect(result.counts.fabricated).toBe(0); + expect(result.counts.unlocated).toBe(1); + }); + it("does not fabricate a bare basename that lives deeper in the tree", () => { // `workspace.ts` in prose is a reference by name, not a path claim; it must // not block just because it is not at the project root.