diff --git a/src/drift/__tests__/path-false-positives.test.ts b/src/drift/__tests__/path-false-positives.test.ts index 6dd8713d..e3fa9c6a 100644 --- a/src/drift/__tests__/path-false-positives.test.ts +++ b/src/drift/__tests__/path-false-positives.test.ts @@ -106,4 +106,38 @@ describe("MISSING_PATH false positives", () => { ".mex/local/", ]); }); + + it("does not claim hypothetical resolution alternatives (#202 bullet 3)", () => { + const markdown = + "# Resolve\n\n" + + "The specifier (`./x` may be `x.ts` or `x/index.ts`); at most one really exists.\n" + + "The handler lives in `src/auth/login.ts`.\n"; + + const projectRoot = mkdtempSync(join(tmpdir(), "mex-drift-")); + execFileSync("git", ["init", "-q"], { cwd: projectRoot }); + const scaffoldRoot = join(projectRoot, ".mex"); + mkdirSync(scaffoldRoot, { recursive: true }); + const docPath = join(scaffoldRoot, "ROUTER.md"); + writeFileSync(docPath, markdown); + + const pathValues = extractClaims(docPath, ".mex/ROUTER.md") + .filter((claim) => claim.kind === "path") + .map((claim) => claim.value); + expect(pathValues).not.toContain("./x"); + expect(pathValues).not.toContain("x.ts"); + expect(pathValues).not.toContain("x/index.ts"); + expect(pathValues).toContain("src/auth/login.ts"); + + const missing = checkPaths( + extractClaims(docPath, ".mex/ROUTER.md"), + projectRoot, + scaffoldRoot + ) + .filter((issue) => issue.code === "MISSING_PATH") + .map((issue) => issue.claim?.value); + expect(missing).not.toContain("./x"); + expect(missing).not.toContain("x.ts"); + expect(missing).not.toContain("x/index.ts"); + expect(missing).toEqual(["src/auth/login.ts"]); + }); }); diff --git a/src/drift/claims.ts b/src/drift/claims.ts index f63b064a..08f6cef1 100644 --- a/src/drift/claims.ts +++ b/src/drift/claims.ts @@ -43,6 +43,31 @@ const DOTTED_KEY_WITH_SLASH = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+\/[A-Za-z0-9_ */ const PACKAGE_NAME = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/; +/** + * Resolution-example stubs, not project files. Import docs write + * `./x` may be `x.ts` or `x/index.ts` to show how a specifier + * resolves; at most one of those names exists, and usually none + * of them name a real file in this repository. + * + * The class is a single-letter basename (optional `./`) alone, plus + * a known extension, or plus `/index` and a known extension. Extra + * dots (`x.d.ts`) are left to the compound-extension filter. Ordinary + * filenames and multi-segment paths (`src/auth/login.ts`, + * `.mex/ROUTER.md`, `package.json`) stay claims. + */ +function isResolutionStubPath(value: string): boolean { + const rest = value.startsWith("./") ? value.slice(2) : value; + if (rest.length === 0 || rest.startsWith(".")) return false; + + if (rest.length === 1) return /[A-Za-z]/.test(rest); + + if (/^[A-Za-z]\.[A-Za-z0-9]+$/.test(rest)) { + return KNOWN_EXTENSIONS.test(rest); + } + + return /^[A-Za-z]\/index\.[A-Za-z0-9]+$/.test(rest) && KNOWN_EXTENSIONS.test(rest); +} + /** Things that look like paths but are actually code snippets, URL routes, or other non-path content */ function isNotAPath(value: string): boolean { // URL routes: /voice/incoming, /api/users — start with / but have no file extension @@ -85,6 +110,9 @@ function isNotAPath(value: string): boolean { // `nodemon src/index.ts` names a runner and its argument. if (/\s/.test(value)) return true; + // Hypothetical resolution alternatives: `./x`, `x.ts`, `x/index.ts` + if (isResolutionStubPath(value)) return true; + return false; } diff --git a/test/claims.test.ts b/test/claims.test.ts index 8185d674..7617f306 100644 --- a/test/claims.test.ts +++ b/test/claims.test.ts @@ -158,6 +158,24 @@ describe("extractClaims — paths", () => { expect(paths[0].value).toBe("api_clients/groq_client.py"); }); + it("skips hypothetical resolution stubs (#202 bullet 3)", () => { + const path = writeFixture( + "test.md", + "# Resolve\n\n" + + "The specifier (`./x` may be `x.ts` or `x/index.ts`); at most one really exists. " + + "The handler lives in `src/auth/login.ts`. " + + "Scaffold paths stay claims: `.mex/ROUTER.md` and `package.json`." + ); + const claims = extractClaims(path, "test.md"); + const paths = claims.filter((c) => c.kind === "path").map((c) => c.value); + expect(paths).not.toContain("./x"); + expect(paths).not.toContain("x.ts"); + expect(paths).not.toContain("x/index.ts"); + expect(paths).toContain("src/auth/login.ts"); + expect(paths).toContain(".mex/ROUTER.md"); + expect(paths).toContain("package.json"); + }); + it("marks paths under negated sections", () => { const path = writeFixture( "test.md",