diff --git a/src/drift/checkers/dependency.ts b/src/drift/checkers/dependency.ts index 3f7bae08..738de608 100644 --- a/src/drift/checkers/dependency.ts +++ b/src/drift/checkers/dependency.ts @@ -1,5 +1,6 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; +import { globSync } from "glob"; import type { Claim, DriftIssue } from "../../types.js"; /** Runtimes, platforms, databases, protocols, and architectural terms that appear in stack docs but aren't installable packages */ @@ -32,6 +33,7 @@ const KNOWN_RUNTIMES = new Set([ "bootstrap", "sass", "less", "postcss", "webpack", "vite", "esbuild", "turbopack", "rollup", "parcel", "git", "github", "gitlab", "ci/cd", "nginx", "apache", "caddy", + "npm", "pnpm", "yarn", "npx", "corepack", "linux", "macos", "windows", "wasm", "webassembly", ]); @@ -123,5 +125,26 @@ function loadAllDependencies(projectRoot: string): DepEntry[] | null { } } + // A repository often keeps a second application in a subdirectory without + // declaring workspaces, and that application's packages are declared in its + // own manifest. Reading only the root one reported every dependency the + // subproject documents as missing. + for (const nested of globSync("*/package.json", { + cwd: projectRoot, + ignore: ["node_modules/**"], + })) { + try { + const pkg = JSON.parse(readFileSync(resolve(projectRoot, nested), "utf-8")); + for (const [name, version] of Object.entries(pkg.dependencies ?? {})) { + entries.push({ name, version: String(version) }); + } + for (const [name, version] of Object.entries(pkg.devDependencies ?? {})) { + entries.push({ name, version: String(version) }); + } + } catch { + // skip + } + } + return entries.length ? entries : null; } diff --git a/src/drift/checkers/path.ts b/src/drift/checkers/path.ts index 892136e3..9112be33 100644 --- a/src/drift/checkers/path.ts +++ b/src/drift/checkers/path.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { resolve } from "node:path"; @@ -7,6 +8,9 @@ import type { Claim, DriftIssue } from "../../types.js"; const PLACEHOLDER_WORDS = /(?:^|[/_-])(?:new|example|your|sample|my|foo|bar|placeholder|template)(?:[/_.-]|$)/i; +/** Naming-convention examples: `PascalCase.tsx` shows a shape, not a file. */ +const NAMING_CONVENTION = /^(?:PascalCase|camelCase|kebab-case|snake_case|SCREAMING_SNAKE_CASE)\./; + /** Scoped package pattern: @scope/name or @scope/name/sub/path */ const SCOPED_PACKAGE = /^@([\w-]+)\/([\w-]+)(\/.*)?$/; @@ -26,13 +30,31 @@ export function checkPaths( // Collect workspace package names once for all claims const workspaceNames = collectWorkspaceNames(projectRoot); + const ignoredPaths = collectIgnoredPaths( + pathClaims.map((c) => c.value), + projectRoot + ); for (const claim of pathClaims) { // URLs are never filesystem paths if (URL_PATTERN.test(claim.value)) continue; + // Naming-convention examples describe a shape, not a file on disk. + if (NAMING_CONVENTION.test(claim.value)) continue; + + // An API route or a placeholder reads exactly like a relative directory + // path -- `documents/upload`, `owner/repo`. What separates them from a real + // reference is that nothing by the name of their first segment exists, so + // treat those as prose rather than reporting a file that was never claimed. + if (isUnrootedReference(claim.value, projectRoot, scaffoldRoot)) continue; + if (pathExists(claim.value, projectRoot, scaffoldRoot, workspaceNames)) continue; + // A path the repository deliberately ignores is created at runtime, so its + // absence from a clean checkout is expected rather than drift: `.mex/local/` + // and `.mex/graph.db` are documented precisely because mex writes them. + if (ignoredPaths.has(claim.value)) continue; + // Downgrade to warning if: from a pattern file or path contains placeholder words. // Bare filenames that aren't found even after recursive search are genuinely missing. const isPattern = claim.source.includes("patterns/"); @@ -52,6 +74,66 @@ export function checkPaths( return issues; } +/** + * True when a slash-separated value names no file type, does not end in a + * directory separator, and its first segment does not exist at either root. + * API routes and placeholders take this shape; a real relative path almost + * always starts from a directory that is actually there. + */ +function isUnrootedReference( + value: string, + projectRoot: string, + scaffoldRoot: string +): boolean { + if (!value.includes("/") || value.startsWith("/") || value.endsWith("/")) return false; + if (/\.[A-Za-z0-9]+$/.test(value)) return false; + + const first = value.split("/")[0]; + if (!first || first.startsWith("@") || first === "." || first === "..") return false; + + if (existsSync(resolve(projectRoot, first))) return false; + if (scaffoldRoot !== projectRoot && existsSync(resolve(scaffoldRoot, first))) return false; + return true; +} + +/** + * Ask Git which of these paths are ignored. Documentation names generated + * state -- a database, a local-only directory -- and that state is absent from + * a clean checkout by design, so reporting it as a missing path is noise. One + * batched call keeps this to a single subprocess per run; a checkout without + * Git simply reports nothing ignored. + */ +function collectIgnoredPaths(values: string[], projectRoot: string): Set { + const ignored = new Set(); + const candidates = [...new Set(values)].filter((v) => v.length > 0); + if (candidates.length === 0) return ignored; + + try { + const output = execFileSync("git", ["check-ignore", "--stdin"], { + cwd: projectRoot, + input: candidates.join("\n"), + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (trimmed) ignored.add(trimmed); + } + } catch (error) { + // Exit code 1 means "nothing ignored" and carries partial stdout; any other + // failure (no Git, not a repository) leaves the set empty. + const stdout = (error as { stdout?: string | Buffer })?.stdout; + if (typeof stdout === "string" || Buffer.isBuffer(stdout)) { + for (const line of stdout.toString().split("\n")) { + const trimmed = line.trim(); + if (trimmed) ignored.add(trimmed); + } + } + } + + return ignored; +} + /** * Collect the `name` field from each workspace's package.json. * Reads the root `workspaces` field (npm, yarn, bun) or falls back to @@ -158,6 +240,33 @@ function pathExists( maxDepth: 5, }); if (matches.length > 0) return true; + + // The project search skips the scaffold, so a scaffold file naming another + // scaffold file -- `INDEX.md`, or a pattern by its filename -- found + // nothing. Search the scaffold too when it is a directory of its own. + if (scaffoldRoot !== projectRoot && existsSync(scaffoldRoot)) { + const inScaffold = globSync(`**/${value}`, { + cwd: scaffoldRoot, + ignore: ["node_modules/**"], + maxDepth: 5, + }); + if (inScaffold.length > 0) return true; + } + } + + // Documentation inside a subproject names paths from that subproject's root: + // a backend's own docs say `routes/quiz.ts`, not + // `server/src/routes/quiz.ts`. Accept the claim when exactly that suffix + // exists somewhere in the repository, so a real file is not reported missing + // because the reader started from a different directory than the author. + if (value.includes("/") && !value.startsWith("/")) { + const suffix = value.replace(/^\.\//, "").replace(/\/$/, ""); + const matches = globSync(`**/${suffix}`, { + cwd: projectRoot, + ignore: ["node_modules/**", "dist/**", ".git/**"], + maxDepth: 6, + }); + if (matches.length > 0) return true; } return false; diff --git a/src/drift/claims.ts b/src/drift/claims.ts index c51952c3..8ad2312e 100644 --- a/src/drift/claims.ts +++ b/src/drift/claims.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { visit } from "unist-util-visit"; import { parseMarkdown, getHeadingAtLine, isNegatedSection } from "../markdown.js"; import type { Claim } from "../types.js"; -import type { Root, Code, InlineCode, Strong, Text } from "mdast"; +import type { Root, Code, InlineCode, ListItem, Strong, Text } from "mdast"; const KNOWN_EXTENSIONS = /\.(ts|js|tsx|jsx|py|go|rs|rb|java|json|yaml|yml|toml|md|css|scss|html|vue|svelte|sh)$/; const COMMAND_PREFIXES = /^(npm|yarn|pnpm|bun|make|cargo|python|pip|go|node|npx|tsx)\s/; @@ -13,6 +13,31 @@ const TEMPLATE_PLACEHOLDER = /[<>\[\]{}]/; /** HTTP methods that indicate an API route, not a file path */ const HTTP_METHOD_PREFIX = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+\//; +/** IP addresses and CIDR ranges are network values, not filesystem paths. */ +const IP_OR_CIDR = /^(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/; + +/** Inline file extension references like `.yaml` describe a type, not a file. */ +const EXTENSION_ONLY = /^\.[A-Za-z0-9]+$/; + +/** Common shell commands that can contain path-like arguments. */ +const SHELL_COMMAND_PREFIX = /^(?:sudo\s+)?(?:ls|cd|cat|grep|find|kubectl|helm|docker|git)\s+/; + +/** + * Dotted config keys or annotations can contain slashes but are not paths: + * `argocd.argoproj.io/sync-wave`, `k8s.io/api`. The dotted segment must start + * with a real character -- anchoring it any looser also matches a hidden + * directory (`.github/CODEOWNERS`, `.mex/ROUTER.md`), which would drop the + * scaffold's own paths out of the check entirely. + */ +const DOTTED_KEY_WITH_SLASH = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+\/[A-Za-z0-9_.-]+$/; + +/** + * An installable package name: optional scope, then name characters only. + * Prose that happens to be bold -- a description, or connective punctuation + * left behind when inline code is stripped out -- does not match. + */ +const PACKAGE_NAME = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/; + /** 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 @@ -21,14 +46,39 @@ function isNotAPath(value: string): boolean { // HTTP method + route: GET /api/bookmarks, POST /users/:id if (HTTP_METHOD_PREFIX.test(value)) return true; + // IP addresses and CIDR ranges: 192.168.5.0/24, 10.0.0.0/8 + if (IP_OR_CIDR.test(value)) return true; + + // File extensions: .yaml, .yml + if (EXTENSION_ONLY.test(value)) return true; + + // Shell commands with path-like arguments: sudo ls /var/lib/kubelet + if (SHELL_COMMAND_PREFIX.test(value)) return true; + + // Annotation/config keys with slash-separated namespaces: argocd.argoproj.io/sync-wave + if (DOTTED_KEY_WITH_SLASH.test(value)) return true; + // Code snippets: contains =, (), ;, or other code-like characters if (/[=();,]/.test(value)) return true; // Quoted strings or attribute assignments: gather.action="...", foo="bar" if (/["']/.test(value)) return true; - // Wildcard prefixes like *_streaming_client.py — patterns, not real paths - if (value.startsWith("*")) return true; + // Elided paths: `/api/api/...` in a troubleshooting note names a shape, not + // a file. + if (value.includes("..")) return true; + + // Home-relative runtime locations: ~/.claude/projects, ~/.config/app.toml. + // Real at runtime, absent from the repository, and never a project file. + if (value.startsWith("~")) return true; + + // Globs anywhere, not just leading: .mex/graph.db*, *_client.py. A pattern + // describes a set of files rather than claiming one exists. + if (/[*?]/.test(value)) return true; + + // Anything with whitespace is a command or a sentence, not a path: + // `nodemon src/index.ts` names a runner and its argument. + if (/\s/.test(value)) return true; return false; } @@ -45,12 +95,39 @@ export function extractClaims(filePath: string, source: string): Claim[] { const tree = parseMarkdown(content); const claims: Claim[] = []; + // A stack doc declares a dependency as `- **name** — description`, so only + // bold that opens a list item is a declaration. Collected up front because + // both the inline-code pass and the bold pass need to know which nodes are + // package names rather than paths or emphasis. + const declarationStrong = new Set(); + const declaredPackage = new Set(); + visit(tree, "listItem", (item: ListItem) => { + const firstBlock = item.children[0]; + if (!firstBlock || firstBlock.type !== "paragraph") return; + const lead = firstBlock.children[0]; + if (!lead || lead.type !== "strong") return; + + const heading = getHeadingAtLine(tree, lead.position?.start.line ?? 0); + if (!heading || !DEPENDENCY_SECTION_PATTERNS.test(heading)) return; + + declarationStrong.add(lead); + for (const child of lead.children) { + if (child.type === "inlineCode") declaredPackage.add(child); + } + }); + + // Extract from inline code visit(tree, "inlineCode", (node: InlineCode) => { const line = node.position?.start.line ?? 0; const heading = getHeadingAtLine(tree, line); const negated = isNegatedSection(heading); + // A package named inside a dependency entry is not a file. `youtubei.js` + // ends in a known extension, so without this it was reported as a + // missing path on every scaffold that documents its packages. + if (declaredPackage.has(node)) return; + // Path claims: contains / or ends in known extension if (node.value.includes("/") || KNOWN_EXTENSIONS.test(node.value)) { // Skip commands, template placeholders, and non-path content @@ -107,39 +184,65 @@ export function extractClaims(filePath: string, source: string): Claim[] { const heading = getHeadingAtLine(tree, line); const negated = isNegatedSection(heading); - if (heading && DEPENDENCY_SECTION_PATTERNS.test(heading)) { - const text = getStrongText(node); - if (!text) return; + if (!heading || !DEPENDENCY_SECTION_PATTERNS.test(heading)) return; - // Check for version pattern: "React 18" or "Node v20" - const versionMatch = text.match(/^(.+?)\s+[v^~>=<]*(\d[\d.]*\S*)$/); - if (versionMatch) { - claims.push({ - kind: "dependency", - value: versionMatch[1].trim(), - source, - line, - section: heading, - negated, - }); - claims.push({ - kind: "version", - value: text, - source, - line, - section: heading, - negated, - }); - } else { + // Only bold that opens a list item declares a dependency. Bold used + // mid-sentence is emphasis on a term -- `the **service-role** key` -- and + // reading it as a package name invented a claim the manifest can never + // satisfy. + if (!declarationStrong.has(node)) return; + + // When the entry names its packages in code, those are the dependency: + // `**Radix UI + \`class-variance-authority\`**` is one package, not a + // package called "Radix UI + ". Reading the surrounding prose instead + // produced claims made of connective punctuation. + const coded = node.children.filter( + (child): child is InlineCode => child.type === "inlineCode" + ); + if (coded.length > 0) { + for (const child of coded) { claims.push({ kind: "dependency", - value: text, + value: child.value, source, line, section: heading, negated, }); } + return; + } + + const text = getStrongText(node); + if (!text) return; + + // Check for version pattern: "React 18" or "Node v20" + const versionMatch = text.match(/^(.+?)\s+[v^~>=<]*(\d[\d.]*\S*)$/); + const name = versionMatch ? versionMatch[1].trim() : text; + + // A description is not a claim. "Supabase (Postgres + Auth)" and + // "Express 4.21 on Node" describe a choice in prose; nothing installable + // carries that name, so checking it against a manifest only ever produces + // a warning the author cannot act on. + if (!PACKAGE_NAME.test(name)) return; + + claims.push({ + kind: "dependency", + value: name, + source, + line, + section: heading, + negated, + }); + if (versionMatch) { + claims.push({ + kind: "version", + value: text, + source, + line, + section: heading, + negated, + }); } }); @@ -147,8 +250,10 @@ export function extractClaims(filePath: string, source: string): Claim[] { } function getStrongText(node: Strong): string | null { - const textNode = node.children.find( - (c): c is Text => c.type === "text" - ); - return textNode?.value ?? null; + const text = node.children + .filter((c): c is Text => c.type === "text") + .map((c) => c.value) + .join("") + .trim(); + return text.length > 0 ? text : null; } diff --git a/src/drift/index.ts b/src/drift/index.ts index b1ed8ac7..4587e8fd 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { resolve, relative, basename } from "node:path"; +import { resolve, relative } from "node:path"; import { globSync } from "glob"; import type { MexConfig, DriftReport, DriftIssue, Claim } from "../types.js"; import { extractClaims } from "./claims.js"; @@ -237,11 +237,23 @@ export async function runDriftCheckWithGraphStatus( } // Run checkers that work on claims - // Only check paths in ROUTER.md — other scaffold files use backticks for - // non-path content (config values, IPs, annotation keys) that produces - // false MISSING_PATH errors. See https://github.com/mex-memory/mex/issues/79 - const routerClaims = allClaims.filter((c) => basename(c.source) === "ROUTER.md"); - const pathIssues = checkPaths(routerClaims, projectRoot, scaffoldRoot); + // Paths are checked in every scaffold file. #80 narrowed this to ROUTER.md + // because non-path inline code produced false MISSING_PATH errors, but that + // left the other ten files unchecked -- a path could rot in context/ or + // patterns/ and nothing said so. The extraction side now rejects the values + // that caused those false positives, so the scope can widen again. + // See https://github.com/mex-memory/mex/issues/107 + // + // A token the scaffold documents as a package is not a missing file: mex + // must not call `youtubei.js` a dependency in context/stack.md and a broken + // path in ROUTER.md. + const declaredPackages = new Set( + allClaims.filter((c) => c.kind === "dependency").map((c) => c.value) + ); + const pathClaims = allClaims.filter( + (c) => c.kind !== "path" || !declaredPackages.has(c.value) + ); + const pathIssues = checkPaths(pathClaims, projectRoot, scaffoldRoot); allIssues.push(...pathIssues); checkerIssueCounts.push(["paths", pathIssues.length]); diff --git a/test/checkers.test.ts b/test/checkers.test.ts index 6839b93f..61671ea4 100644 --- a/test/checkers.test.ts +++ b/test/checkers.test.ts @@ -6,6 +6,7 @@ import { rmSync, } from "node:fs"; import { join } from "node:path"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { checkPaths } from "../src/drift/checkers/path.js"; import { checkEdges } from "../src/drift/checkers/edges.js"; @@ -57,6 +58,52 @@ describe("checkPaths", () => { expect(issues[0].code).toBe("MISSING_PATH"); }); + it("skips runtime paths the repository ignores", () => { + execFileSync("git", ["init", "-q"], { cwd: tmpDir }); + writeFileSync(join(tmpDir, ".gitignore"), "generated/\n*.db\n"); + const claims = [ + claim({ kind: "path", value: "generated/" }), + claim({ kind: "path", value: "graph.db" }), + ]; + expect(checkPaths(claims, tmpDir, tmpDir)).toHaveLength(0); + }); + + it("finds a scaffold file named from another scaffold file", () => { + const mexDir = join(tmpDir, ".mex"); + mkdirSync(join(mexDir, "patterns"), { recursive: true }); + writeFileSync(join(mexDir, "patterns/INDEX.md"), ""); + const claims = [claim({ kind: "path", value: "INDEX.md" })]; + expect(checkPaths(claims, tmpDir, mexDir)).toHaveLength(0); + }); + + it("resolves a path written from a subproject's own root", () => { + mkdirSync(join(tmpDir, "server/src/routes"), { recursive: true }); + writeFileSync(join(tmpDir, "server/src/routes/quiz.ts"), ""); + const claims = [claim({ kind: "path", value: "routes/quiz.ts" })]; + expect(checkPaths(claims, tmpDir, tmpDir)).toHaveLength(0); + }); + + it("skips API routes and placeholders whose first segment does not exist", () => { + const claims = [ + claim({ kind: "path", value: "documents/upload" }), + claim({ kind: "path", value: "owner/repo" }), + ]; + expect(checkPaths(claims, tmpDir, tmpDir)).toHaveLength(0); + }); + + it("still reports a missing path under a directory that does exist", () => { + mkdirSync(join(tmpDir, "src"), { recursive: true }); + const claims = [claim({ kind: "path", value: "src/missing.ts" })]; + const issues = checkPaths(claims, tmpDir, tmpDir); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("MISSING_PATH"); + }); + + it("skips naming-convention examples", () => { + const claims = [claim({ kind: "path", value: "PascalCase.tsx" })]; + expect(checkPaths(claims, tmpDir, tmpDir)).toHaveLength(0); + }); + it("passes for existing paths", () => { mkdirSync(join(tmpDir, "src"), { recursive: true }); writeFileSync(join(tmpDir, "src/index.ts"), ""); diff --git a/test/claims.test.ts b/test/claims.test.ts index 424ff4bb..1f03e818 100644 --- a/test/claims.test.ts +++ b/test/claims.test.ts @@ -94,6 +94,47 @@ describe("extractClaims — paths", () => { expect(paths).toHaveLength(0); }); + it("skips non-path inline code values", () => { + const path = writeFixture( + "test.md", + "# Notes\n\n" + + "The cluster subnet is `192.168.5.0/24`. " + + "The ArgoCD annotation `argocd.argoproj.io/sync-wave` controls ordering. " + + "Use `sudo ls /var/lib/kubelet/plugins_registry/` to inspect plugins. " + + "YAML files can use `.yaml` or `.yml`." + ); + const claims = extractClaims(path, "test.md"); + const paths = claims.filter((c) => c.kind === "path"); + expect(paths).toHaveLength(0); + }); + + it("skips runtime, glob, and command-shaped values", () => { + const path = writeFixture( + "test.md", + "# Notes\n\n" + + "Transcripts live in `~/.claude/projects`. " + + "Backups match `.mex/graph.db*`. " + + "Dev runs `nodemon src/index.ts`. " + + "A doubled call passes `api/...` to the helper." + ); + const claims = extractClaims(path, "test.md"); + expect(claims.filter((c) => c.kind === "path")).toHaveLength(0); + }); + + it("still extracts hidden-directory paths that a dotted key would swallow", () => { + const path = writeFixture( + "test.md", + "# Notes\n\n" + + "Ownership lives in `.github/CODEOWNERS` and CI in `.github/workflows`. " + + "The scaffold anchor is `.mex/ROUTER.md`." + ); + const claims = extractClaims(path, "test.md"); + const paths = claims.filter((c) => c.kind === "path").map((c) => c.value); + expect(paths).toContain(".github/CODEOWNERS"); + expect(paths).toContain(".github/workflows"); + expect(paths).toContain(".mex/ROUTER.md"); + }); + it("extracts bare filenames as path claims", () => { const path = writeFixture( "test.md", @@ -200,6 +241,70 @@ describe("extractClaims — dependencies", () => { expect(versions.map((v) => v.value)).toContain("Node v20"); }); + it("claims the packages named in code inside a bold dependency entry", () => { + const path = writeFixture( + "test.md", + "# Key Libraries\n\n" + + "- **`@xyflow/react` + `dagre`** — mind-map rendering and auto-layout\n" + + "- **Radix UI + `class-variance-authority` + `tailwind-merge`** — primitives" + ); + const claims = extractClaims(path, "test.md"); + const deps = claims.filter((c) => c.kind === "dependency").map((d) => d.value); + expect(deps).toEqual([ + "@xyflow/react", + "dagre", + "class-variance-authority", + "tailwind-merge", + ]); + }); + + it("does not claim a prose description as a dependency", () => { + const path = writeFixture( + "test.md", + "# Core Technologies\n\n" + + "- **Supabase (Postgres + Auth)** — single datastore and identity provider\n" + + "- **Express 4.21 on Node** — the API server\n" + + "- **GitHub public REST API** — unauthenticated fetches" + ); + const claims = extractClaims(path, "test.md"); + const deps = claims.filter((c) => c.kind === "dependency"); + expect(deps).toHaveLength(0); + }); + + it("ignores bold emphasis inside a list item's prose", () => { + const path = writeFixture( + "test.md", + "# External Dependencies\n\n" + + "- **Groq (`groq-sdk`)** — completions; the backend uses the **service-role** key" + ); + const claims = extractClaims(path, "test.md"); + const deps = claims.filter((c) => c.kind === "dependency").map((d) => d.value); + expect(deps).toEqual(["groq-sdk"]); + }); + + it("does not treat a package named in a dependency entry as a path", () => { + const path = writeFixture( + "test.md", + "# Key Libraries\n\n- **YouTube via `youtubei.js`** — transcript retrieval" + ); + const claims = extractClaims(path, "test.md"); + expect(claims.filter((c) => c.kind === "path")).toHaveLength(0); + expect(claims.filter((c) => c.kind === "dependency").map((d) => d.value)).toEqual([ + "youtubei.js", + ]); + }); + + it("still claims a real path written in a non-dependency section", () => { + const path = writeFixture( + "test.md", + "# Architecture\n\n- **Entry point** — `src/index.ts` boots the server" + ); + const claims = extractClaims(path, "test.md"); + expect(claims.filter((c) => c.kind === "path").map((c) => c.value)).toEqual([ + "src/index.ts", + ]); + }); + it("ignores bold text outside dependency sections", () => { const path = writeFixture( "test.md", diff --git a/test/public-api.test.ts b/test/public-api.test.ts index c5c7b53f..eef25499 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -284,14 +284,15 @@ describe("public API — runDriftCheck", () => { }); }); -describe("public API — runDriftCheck scopes checkPaths to ROUTER.md", () => { - it("does not produce MISSING_PATH issues from non-ROUTER.md files", async () => { +describe("public API — runDriftCheck path claims", () => { + it("does not produce MISSING_PATH issues from non-path inline code", async () => { mkdirSync(join(tmpDir, ".mex/context"), { recursive: true }); writeFileSync( join(tmpDir, ".mex/ROUTER.md"), "---\nedges:\n - target: context/architecture.md\n---\n# Router\n\nSee [architecture](context/architecture.md).\n", ); - // architecture.md has inline code that looks like paths but isn't real files + // architecture.md is checked too, so its inline code must be recognised as + // config values rather than files writeFileSync( join(tmpDir, ".mex/context/architecture.md"), "# Architecture\n\nUse `csi.kubeletRootDir: /var/lib/kubelet` and `192.168.5.0/24`.\n", @@ -313,7 +314,7 @@ describe("public API — runDriftCheck scopes checkPaths to ROUTER.md", () => { expect(pathIssues[0].message).toContain("src/totally/missing.ts"); }); - it("only flags ROUTER.md paths when both ROUTER.md and AGENTS.md have missing paths", async () => { + it("flags missing paths in every scaffold file, not only ROUTER.md", async () => { writeFileSync( join(tmpDir, ".mex/ROUTER.md"), "# Router\n\nSee `src/missing.ts`.\n", @@ -324,9 +325,13 @@ describe("public API — runDriftCheck scopes checkPaths to ROUTER.md", () => { ); const report = await runDriftCheck(config); const pathIssues = report.issues.filter((i) => i.code === "MISSING_PATH"); - // Only the ROUTER.md path should be flagged - expect(pathIssues).toHaveLength(1); - expect(pathIssues[0].message).toContain("src/missing.ts"); + // #80 scoped this to ROUTER.md, which left the rest of the scaffold + // unchecked. Both files make a path claim, so both are answered for. + expect(pathIssues).toHaveLength(2); + expect(pathIssues.map((i) => i.file).sort()).toEqual([ + ".mex/AGENTS.md", + ".mex/ROUTER.md", + ]); }); });