From 9b0a49d123ed2c3c9238b633d4eac4c5b2ddafa7 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sun, 9 Aug 2026 14:10:43 -0500 Subject: [PATCH 1/3] fix(drift): resolve cited paths in every frame they are legitimately written in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift resolved every reference from the repo root plus two fixed ancestors, and measurement put the false-positive floor near 60%. Instruction files cite paths in frames that resolution never tried. scripts/lib/path-resolution.ts collects those frames — workspace package roots read from package.json `workspaces` and pnpm-workspace.yaml, ancestor roots, declared package names, and tsconfig `paths` aliases — and a reference is only reported when it resolves NOWHERE. Measured on the eight fixture repos, path issues 87 -> 30: vision 6 -> 1 watchtower 7 -> 0 currychat 1 -> 0 lc-classic-starter 3 -> 0 360training 43 -> 19 atlas 15 -> 9 recall 11 -> 1 forge 1 -> 1 All four must-stay-quiet repos are quiet. `~` is matched against tsconfig aliases BEFORE $HOME, because both meanings are live: currychat's is an alias, lc-classic-starter's is a home, and expanding unconditionally only trades one false-positive class for another. Two corrections to the brief's diagnosis, both found by measuring: The `mdc:` fix is not about the scheme. PATH_PATTERN is `\b`-anchored and there is no word boundary between a delimiter and a leading dot, so the dot is lost whether or not `mdc:` is stripped — my first attempt removed the scheme and changed nothing. Restoring a dropped leading dot at resolution time is what actually fixes it, and it covers every dot-prefixed path, not just Cursor's. `@360training/ui` is not a declared package name and so is not a false positive. That workspace publishes `@t360/*`; the only `@360training/*` package is cloudflare-proxy. It is a stale specifier and stays flagged, correctly. Not taken: making pattern-doc references non-issues. A golden test already encodes the opposite contract — pattern docs skip cross-project examples but KEEP missing local doc refs — and one forge example is not enough to overturn a tested decision. forge's `data/action-log.md` therefore stays flagged. pathIssues is still NOT wired into the verdict. 28 of the remaining 30 sit in two repos and belong to classes nobody has sorted yet. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/drift-detect.ts | 20 ++ scripts/lib/path-resolution.test.ts | 106 +++++++++++ scripts/lib/path-resolution.ts | 281 ++++++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 scripts/lib/path-resolution.test.ts create mode 100644 scripts/lib/path-resolution.ts diff --git a/scripts/drift-detect.ts b/scripts/drift-detect.ts index 66c40d6..22b3ca8 100644 --- a/scripts/drift-detect.ts +++ b/scripts/drift-detect.ts @@ -23,6 +23,11 @@ import { homedir } from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; import { discoverRuleSurfaceFiles } from "./lib/rule-surface.ts"; import { resolveProjectName } from "./lib/project-name.ts"; +import { + buildResolutionContext, + pointsIntoSkippedDir, + resolvesSomewhere, +} from "./lib/path-resolution.ts"; export interface DriftIssue { type: "path" | "glob" | "command" | "date" | "coverage-gap"; @@ -738,6 +743,7 @@ export function detectPathDrift( basename(parentRoot) === "projects" ? resolve(parentRoot, "..") : parentRoot; + const resolutionContext = buildResolutionContext(projectRoot); for (const filePath of files) { if (basename(filePath) === "drift-report.md") { @@ -839,6 +845,13 @@ export function detectPathDrift( continue; } + if ( + pointsIntoSkippedDir(btPath) || + resolvesSomewhere(btPath, resolutionContext) + ) { + continue; + } + issues.push({ type: "path", file: relativeFile, @@ -949,6 +962,13 @@ export function detectPathDrift( continue; } + if ( + pointsIntoSkippedDir(reference) || + resolvesSomewhere(reference, resolutionContext) + ) { + continue; + } + issues.push({ type: "path", file: relativeFile, diff --git a/scripts/lib/path-resolution.test.ts b/scripts/lib/path-resolution.test.ts new file mode 100644 index 0000000..fb3b0b9 --- /dev/null +++ b/scripts/lib/path-resolution.test.ts @@ -0,0 +1,106 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + buildResolutionContext, + pointsIntoSkippedDir, + resolvesSomewhere, +} from "./path-resolution.ts"; + +const created: string[] = []; +afterAll(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); +}); + +/** A monorepo shaped like watchtower/currychat: apps/* with their own tsconfig. */ +function makeMonorepo(): string { + const root = mkdtempSync(join(tmpdir(), "anvil-paths-")); + created.push(root); + mkdirSync(join(root, "apps", "web", "app", "routes"), { recursive: true }); + mkdirSync(join(root, "packages", "ui", "src"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "root", workspaces: ["apps/*", "packages/*"] }), + ); + writeFileSync( + join(root, "packages", "ui", "package.json"), + JSON.stringify({ name: "@wt/ui" }), + ); + writeFileSync( + join(root, "apps", "web", "package.json"), + JSON.stringify({ name: "@wt/web" }), + ); + writeFileSync( + join(root, "apps", "web", "tsconfig.json"), + JSON.stringify({ compilerOptions: { paths: { "~/*": ["./app/*"] } } }), + ); + writeFileSync(join(root, "apps", "web", "app", "routes", "__root.tsx"), ""); + mkdirSync(join(root, "apps", "web", "app", "components"), { + recursive: true, + }); + writeFileSync( + join(root, "apps", "web", "app", "components", "checkbox.tsx"), + "", + ); + mkdirSync(join(root, ".cursor", "rules"), { recursive: true }); + writeFileSync(join(root, ".cursor", "rules", "react-router.mdc"), ""); + return root; +} + +test("a path relative to a workspace package resolves", () => { + // watchtower cites `app/routes/__root.tsx` meaning apps/web/app/routes/... + const root = makeMonorepo(); + const context = buildResolutionContext(root); + + expect(resolvesSomewhere("app/routes/__root.tsx", context)).toBe(true); + expect(resolvesSomewhere("app/routes/nope.tsx", context)).toBe(false); +}); + +test("a declared package name is a specifier, not a path", () => { + const root = makeMonorepo(); + const context = buildResolutionContext(root); + + expect(resolvesSomewhere("@wt/ui", context)).toBe(true); + expect(resolvesSomewhere("@wt/ui/button", context)).toBe(true); + // An undeclared scope is a stale specifier and must stay flagged — 360training + // cites `@360training/ui` while the workspace actually publishes `@t360/ui`. + expect(resolvesSomewhere("@other/ui", context)).toBe(false); +}); + +test("`~` is a tsconfig alias before it is a home directory", () => { + const root = makeMonorepo(); + const context = buildResolutionContext(root); + + // currychat's meaning: ~/components/... -> apps/*/app/components/... + expect(resolvesSomewhere("~/components/checkbox", context)).toBe(true); + expect(resolvesSomewhere("~/components/missing", context)).toBe(false); +}); + +test("a leading dot dropped by the scanner is restored", () => { + // Cursor writes [x](mdc:.cursor/rules/x.mdc); the scanner is `\b`-anchored so + // the dot is already gone by the time the reference is checked. + const root = makeMonorepo(); + const context = buildResolutionContext(root); + + expect(resolvesSomewhere("cursor/rules/react-router.mdc", context)).toBe( + true, + ); + expect(resolvesSomewhere("cursor/rules/absent.mdc", context)).toBe(false); +}); + +test("references into skipped directories are never resolvable", () => { + expect(pointsIntoSkippedDir("node_modules/qmd/src/store.ts")).toBe(true); + expect(pointsIntoSkippedDir("dist/index.js")).toBe(true); + expect(pointsIntoSkippedDir("app/node_modules_helper.ts")).toBe(false); +}); + +test("a genuinely absent path still resolves nowhere", () => { + // The guard against resolving our way to silence. + const root = makeMonorepo(); + const context = buildResolutionContext(root); + + expect(resolvesSomewhere("apps/todo-app/TESTING.md", context)).toBe(false); + expect(resolvesSomewhere("src/does-not-exist.ts", context)).toBe(false); +}); diff --git a/scripts/lib/path-resolution.ts b/scripts/lib/path-resolution.ts new file mode 100644 index 0000000..e46a10a --- /dev/null +++ b/scripts/lib/path-resolution.ts @@ -0,0 +1,281 @@ +/** + * Where a cited path is allowed to resolve. + * + * Drift used to resolve every reference from the repo root, plus two fixed + * ancestors. Measured across 35 repos that produced a ~60% false-positive floor, + * because instruction files legitimately cite paths in four other frames: + * + * - relative to a workspace package (`app/routes/__root.tsx` under `apps/web/`) + * - relative to an ancestor repo (`packages/vision/src/x.ts` cited from inside + * `packages/vision`, where the frame is the saffron root) + * - as a package specifier, not a path at all (`@watchtower/ui`) + * - through a tsconfig alias (`~/components/...` meaning `./app/components/...`) + * + * None of those are drift. This module collects the frames a reference may + * legitimately resolve in, so only references that resolve *nowhere* are + * reported. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +export type ResolutionContext = { + /** Directories a relative reference may resolve against. */ + roots: string[]; + /** Declared workspace package names — specifiers, not paths. */ + packageNames: Set; + /** tsconfig `paths` prefixes, e.g. `~/` -> [`/app/`]. */ + aliases: Array<{ prefix: string; targets: string[] }>; +}; + +/** Directories drift never scans, so a reference into one cannot resolve. */ +export const UNRESOLVABLE_DIRS = [ + "node_modules", + ".git", + "dist", + "build", + ".next", + "coverage", +] as const; + +export function pointsIntoSkippedDir(reference: string): boolean { + return UNRESOLVABLE_DIRS.some( + (dir) => reference === dir || reference.startsWith(`${dir}/`), + ); +} + +function readJson(path: string): Record | null { + try { + // tsconfig files carry comments; strip the simple cases before parsing. + const raw = readFileSync(path, "utf8") + .replace(/^\s*\/\/.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, ""); + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +/** Workspace globs from package.json `workspaces` or pnpm-workspace.yaml. */ +function workspaceGlobs(repoRoot: string): string[] { + const globs: string[] = []; + const pkg = readJson(join(repoRoot, "package.json")); + const declared = pkg?.workspaces; + if (Array.isArray(declared)) { + globs.push(...declared.filter((g): g is string => typeof g === "string")); + } else if ( + declared && + typeof declared === "object" && + Array.isArray((declared as { packages?: unknown }).packages) + ) { + globs.push( + ...(declared as { packages: unknown[] }).packages.filter( + (g): g is string => typeof g === "string", + ), + ); + } + try { + const yaml = readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8"); + for (const line of yaml.split("\n")) { + const match = /^\s*-\s*["']?([^"'\n]+)["']?\s*$/.exec(line); + if (match?.[1]) { + globs.push(match[1].trim()); + } + } + } catch { + // no pnpm workspace file + } + // Common layouts, even when undeclared — a monorepo without a workspaces key + // still cites paths relative to its apps. + globs.push("apps/*", "packages/*", "services/*"); + return globs; +} + +/** Expand a one-level `dir/*` glob; other shapes are used literally. */ +function expandGlob(repoRoot: string, glob: string): string[] { + const cleaned = glob.replace(/\/\*\*$/, "/*").replace(/\/$/, ""); + if (!cleaned.endsWith("/*")) { + const literal = join(repoRoot, cleaned); + return existsSync(literal) ? [literal] : []; + } + const base = join(repoRoot, cleaned.slice(0, -2)); + if (!existsSync(base)) { + return []; + } + try { + return readdirSync(base) + .map((entry) => join(base, entry)) + .filter((full) => { + try { + return statSync(full).isDirectory(); + } catch { + return false; + } + }); + } catch { + return []; + } +} + +/** + * Ancestor directories that look like a repo or workspace root. + * + * `packages/vision/AGENTS.md` cites `packages/vision/src/x.ts` — correct for a + * reader at the saffron root. Walking up to the outermost ancestor holding a + * package.json or .git covers that without hard-coding directory names, which + * the previous "is the parent called projects?" rule did. + */ +function ancestorRoots(repoRoot: string): string[] { + const roots: string[] = []; + let current = dirname(repoRoot); + for (let depth = 0; depth < 4; depth++) { + if (!current || current === "/" || basename(current) === "") { + break; + } + roots.push(current); + current = dirname(current); + } + return roots; +} + +function collectPackageNames(roots: string[]): Set { + const names = new Set(); + for (const root of roots) { + const pkg = readJson(join(root, "package.json")); + const name = pkg?.name; + if (typeof name === "string" && name.length > 0) { + names.add(name); + } + } + return names; +} + +function collectAliases( + repoRoot: string, + packageRoots: string[], +): ResolutionContext["aliases"] { + const aliases: ResolutionContext["aliases"] = []; + for (const root of [repoRoot, ...packageRoots]) { + for (const file of ["tsconfig.json", "tsconfig.base.json"]) { + const config = readJson(join(root, file)); + const paths = (config?.compilerOptions as { paths?: unknown } | undefined) + ?.paths; + if (!paths || typeof paths !== "object") { + continue; + } + for (const [key, value] of Object.entries( + paths as Record, + )) { + if (!Array.isArray(value)) { + continue; + } + const prefix = key.replace(/\*$/, ""); + const targets = value + .filter((v): v is string => typeof v === "string") + .map((v) => resolve(root, v.replace(/\*$/, ""))); + if (prefix && targets.length > 0) { + aliases.push({ prefix, targets }); + } + } + } + } + return aliases; +} + +export function buildResolutionContext(repoRoot: string): ResolutionContext { + const packageRoots = [ + ...new Set( + workspaceGlobs(repoRoot).flatMap((glob) => expandGlob(repoRoot, glob)), + ), + ]; + const roots = [ + ...new Set([repoRoot, ...packageRoots, ...ancestorRoots(repoRoot)]), + ]; + return { + roots, + packageNames: collectPackageNames([repoRoot, ...packageRoots]), + aliases: collectAliases(repoRoot, packageRoots), + }; +} + +/** + * Whether a cited reference resolves in any legitimate frame. + * + * `~` is checked against tsconfig aliases FIRST, then as a home directory. Both + * meanings are live in this fleet — currychat's `~/components/...` is an alias + * while lc-classic-starter's `~/saffron/...` is a home — and expanding to $HOME + * unconditionally just trades one false-positive class for another. + */ +export function resolvesSomewhere( + reference: string, + context: ResolutionContext, +): boolean { + if (context.packageNames.has(reference)) { + return true; + } + // A scoped specifier's subpath (`@watchtower/ui/button`) is still a specifier. + for (const name of context.packageNames) { + if (reference.startsWith(`${name}/`)) { + return true; + } + } + + if (reference.startsWith("~")) { + const rest = reference.slice(1).replace(/^\//, ""); + for (const alias of context.aliases) { + if (!alias.prefix.startsWith("~")) { + continue; + } + const aliasRest = reference.slice(alias.prefix.length); + if ( + alias.targets.some( + (target) => + existsSync(join(target, aliasRest)) || + resolveWithExtensions(join(target, aliasRest)), + ) + ) { + return true; + } + } + if (existsSync(join(homedir(), rest))) { + return true; + } + } + + if (reference.startsWith("/")) { + return existsSync(reference); + } + + const candidates = [reference]; + // The unbackticked scanner is anchored on `\b`, and there is no word boundary + // between a delimiter and a leading dot — so `(mdc:.cursor/rules/x.mdc)` and + // `(./.config/y)` both arrive here with the dot already gone, naming a path + // that can never exist. Restoring it is what actually fixes Cursor's `mdc:` + // links; stripping the scheme, which is where I first reached, changes + // nothing because the dot is lost either way. + if (!reference.startsWith(".")) { + candidates.push(`.${reference}`); + } + + return candidates.some((candidate) => + context.roots.some( + (root) => + existsSync(resolve(root, candidate)) || + resolveWithExtensions(resolve(root, candidate)), + ), + ); +} + +/** Alias targets are usually written without an extension. */ +function resolveWithExtensions(candidate: string): boolean { + return [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".json", + "/index.ts", + "/index.tsx", + ].some((suffix) => existsSync(`${candidate}${suffix}`)); +} From f5e2ae791c64d024e75270f2db3e47069e40ad49 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sun, 9 Aug 2026 14:11:20 -0500 Subject: [PATCH 2/3] chore: bump 0.1.0-alpha.14 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++++---- docs-site/public/llms-full.txt | 2 +- docs-site/src/content/docs/reference/cli.md | 2 +- docs/byok-trust-model.md | 2 +- docs/first-user-proof-packet.md | 6 +++--- docs/first-user-proof.md | 4 ++-- docs/getting-started.md | 6 +++--- docs/proofs/current-outside-tester-send-packet.md | 14 +++++++------- package.json | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b6f3100..da10f94 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Relative `--target` paths resolve from your current shell cwd. If you are alread Choose the lane that matches your setup before your first run. -If you are collecting the outside-user proof, stay on the exact pinned version and launcher from [First User Proof](docs/first-user-proof.md) instead of switching to the unpinned examples in this README. The current pinned `0.1.0-alpha.13` proof packet uses one repo-root `bunx` command with `--ci --output ./anvil-audit.md` so the saved report comes back from the first run. +If you are collecting the outside-user proof, stay on the exact pinned version and launcher from [First User Proof](docs/first-user-proof.md) instead of switching to the unpinned examples in this README. The current pinned `0.1.0-alpha.14` proof packet uses one repo-root `bunx` command with `--ci --output ./anvil-audit.md` so the saved report comes back from the first run. ### Local-only first pass (no provider required) @@ -151,7 +151,7 @@ bun run ./bin/anvil.ts --version Verified on the current alpha packet: - `--help` prints the four shipped entry commands: `audit`, `drift`, `bootstrap`, `mine-pr` -- `--version` prints `0.1.0-alpha.13` +- `--version` prints `0.1.0-alpha.14` Why you might choose this lane: @@ -173,10 +173,10 @@ For first-run setup and CI/lint guidance, see: Lambda Curry maintains this project with internal automation behind it, but that machinery is secondary to the public product path above. -- **Status:** Report as Decision Tool shipped; current charter follow-through is to collect outside-Lambda-Curry first-run proof on pinned `0.1.0-alpha.13` +- **Status:** Report as Decision Tool shipped; current charter follow-through is to collect outside-Lambda-Curry first-run proof on pinned `0.1.0-alpha.14` - **Verification posture:** CI artifact (audit report) + downstream observed impact in rule quality - **Current checked-in self-audit:** `docs/audits/anvil-audit-2026-08-08.md` reports `98/100` Structural Lint, `35/35` Guardrail Readiness, `0` issues, and `0` remediation tasks on current `main` -- **Current proof packet:** `docs/proofs/current-outside-tester-send-packet.md` keeps the external proof lane on one canonical repo-root command that saves `./anvil-audit.md`; the pinned packet stays on `@lambdacurry/anvil@0.1.0-alpha.13` +- **Current proof packet:** `docs/proofs/current-outside-tester-send-packet.md` keeps the external proof lane on one canonical repo-root command that saves `./anvil-audit.md`; the pinned packet stays on `@lambdacurry/anvil@0.1.0-alpha.14` Anvil is not primarily a UI project. Its real proof surface is whether downstream outputs and consumers reflect the intended rule behavior correctly. diff --git a/docs-site/public/llms-full.txt b/docs-site/public/llms-full.txt index 71d2fb4..a999d01 100644 --- a/docs-site/public/llms-full.txt +++ b/docs-site/public/llms-full.txt @@ -745,7 +745,7 @@ anvil audit --target ./my-repo [options] Relative `--target` paths resolve from your current shell cwd. -If you arrived here from the external first-user proof docs, use the exact pinned command from that packet. The current `0.1.0-alpha.13` packet uses the public `--ci` spelling; `--no-ai` remains only as a deprecated compatibility alias. +If you arrived here from the external first-user proof docs, use the exact pinned command from that packet. The current `0.1.0-alpha.14` packet uses the public `--ci` spelling; `--no-ai` remains only as a deprecated compatibility alias. ## `anvil drift` diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index e9a333e..4de0644 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -32,7 +32,7 @@ anvil audit --target ./my-repo [options] Relative `--target` paths resolve from your current shell cwd. -If you arrived here from the external first-user proof docs, use the exact pinned command from that packet. The current `0.1.0-alpha.13` packet uses the public `--ci` spelling; `--no-ai` remains only as a deprecated compatibility alias. +If you arrived here from the external first-user proof docs, use the exact pinned command from that packet. The current `0.1.0-alpha.14` packet uses the public `--ci` spelling; `--no-ai` remains only as a deprecated compatibility alias. ## `anvil drift` diff --git a/docs/byok-trust-model.md b/docs/byok-trust-model.md index 86b6a22..8d17086 100644 --- a/docs/byok-trust-model.md +++ b/docs/byok-trust-model.md @@ -18,7 +18,7 @@ By default, Anvil scans your repo locally, then expects a working AI provider fo If you want the privacy-first path, run: -> **Current alpha note:** The published `0.1.0-alpha.13` proof packet uses one canonical repo-root `bunx` command with `--ci --output ./anvil-audit.md`. Packaged relative `--target` and `--output` paths still resolve from your shell cwd, so normal repo-relative first-run commands are honest when you use the unpinned command (it tracks the latest published build). +> **Current alpha note:** The published `0.1.0-alpha.14` proof packet uses one canonical repo-root `bunx` command with `--ci --output ./anvil-audit.md`. Packaged relative `--target` and `--output` paths still resolve from your shell cwd, so normal repo-relative first-run commands are honest when you use the unpinned command (it tracks the latest published build). ```bash # zero-install diff --git a/docs/first-user-proof-packet.md b/docs/first-user-proof-packet.md index 6d6a4ef..9ef369a 100644 --- a/docs/first-user-proof-packet.md +++ b/docs/first-user-proof-packet.md @@ -20,7 +20,7 @@ Send back whether it worked first try, the first useful fix the report pointed t bunx @lambdacurry/anvil@ audit --target . --ci --output ./anvil-audit.md ``` -Replace `` with the specific published build you want validated. The current `0.1.0-alpha.13` proof packet sends only the repo-root saved-report command above so the artifact comes back from the same first run without asking the tester to choose between layouts. +Replace `` with the specific published build you want validated. The current `0.1.0-alpha.14` proof packet sends only the repo-root saved-report command above so the artifact comes back from the same first run without asking the tester to choose between layouts. Helpful docs: - Getting started: https://lambda-curry.github.io/anvil/getting-started/first-audit @@ -54,7 +54,7 @@ Before sending the note above, make sure: ## 3. Exact command blocks to send -Pick one install path and one shell layout, then send only that exact command so the tester is not choosing between multiple moving parts. For the current `0.1.0-alpha.13` packet, the canonical layout is Bun zero-install from the target repo root. +Pick one install path and one shell layout, then send only that exact command so the tester is not choosing between multiple moving parts. For the current `0.1.0-alpha.14` packet, the canonical layout is Bun zero-install from the target repo root. Replace `` before you send anything. Do not use the floating `@alpha` tag in the external proof packet. @@ -128,7 +128,7 @@ bun run verify:first-user-proof -- docs/proofs/YYYY-MM-DD--first-user-pr ``` The validator returns a deterministic `counts` / `does-not-count` result and names the missing proof fields or contract mismatches directly. -For the current pinned `0.1.0-alpha.13` proof lane, that includes checking that the retained audit command keeps the packet's `--ci` spelling. +For the current pinned `0.1.0-alpha.14` proof lane, that includes checking that the retained audit command keeps the packet's `--ci` spelling. When the packet keeps a local report artifact, it also requires `Saved report path or screenshot link` to match the retained audit command's `--output` path. Save one small packet with these fields: diff --git a/docs/first-user-proof.md b/docs/first-user-proof.md index a786fd7..931e6d3 100644 --- a/docs/first-user-proof.md +++ b/docs/first-user-proof.md @@ -15,7 +15,7 @@ Capture one real outside-Lambda-Curry run that proves: Do this only after the exact published version you want to validate is live, and before Milestone 3 is called complete. -Do not send this packet with the floating `@alpha` tag. Replace `` in the command below with the specific published build you are validating, for example `0.1.0-alpha.13`. +Do not send this packet with the floating `@alpha` tag. Replace `` in the command below with the specific published build you are validating, for example `0.1.0-alpha.14`. ## Suggested tester profile @@ -115,7 +115,7 @@ bun run verify:first-user-proof -- docs/proofs/YYYY-MM-DD--first-user-pr ``` That validator checks the outside-tester status, pinned CLI version, first-try success, returned artifact, and other minimum packet fields, then returns `counts` or `does-not-count` with explicit reasons. -For the current pinned `0.1.0-alpha.13` proof lane, it requires the retained audit command to keep the exact `--ci` spelling from the packet. +For the current pinned `0.1.0-alpha.14` proof lane, it requires the retained audit command to keep the exact `--ci` spelling from the packet. ## Done signal for Milestone 3 gate diff --git a/docs/getting-started.md b/docs/getting-started.md index 16be489..f1371d9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -64,7 +64,7 @@ anvil --version What you should see in the current alpha: - `--help` lists the four shipped commands: `audit`, `drift`, `bootstrap`, `mine-pr` -- `--version` prints `0.1.0-alpha.13` +- `--version` prints `0.1.0-alpha.14` If you are validating Anvil from a cloned checkout instead of a global install, run: @@ -125,7 +125,7 @@ Top 5 improvements: ## Save the report to a file -> **Current alpha note:** The published `0.1.0-alpha.13` proof packet uses one canonical repo-root `bunx` command with `--ci --output ./anvil-audit.md`, while the packaged CLI still resolves relative `--target` and `--output` paths from your shell cwd on `bunx`, `npx`, and Bun global install. Normal relative-path examples are honest when you use the unpinned command (it tracks the latest published build). +> **Current alpha note:** The published `0.1.0-alpha.14` proof packet uses one canonical repo-root `bunx` command with `--ci --output ./anvil-audit.md`, while the packaged CLI still resolves relative `--target` and `--output` paths from your shell cwd on `bunx`, `npx`, and Bun global install. Normal relative-path examples are honest when you use the unpinned command (it tracks the latest published build). ```bash # zero-install with bunx @@ -235,7 +235,7 @@ anvil audit \ `--ci` keeps discovery, drift detection, coverage scoring, and markdown output local. The report headline becomes `Structural Lint Score`, and the improvement section is generated from repo-local heuristics instead of a provider. -`--no-ai` still works as a deprecated compatibility alias for the same mode. The current external first-user proof packet stays pinned to `0.1.0-alpha.13` and uses `--ci` for the local-only lane. +`--no-ai` still works as a deprecated compatibility alias for the same mode. The current external first-user proof packet stays pinned to `0.1.0-alpha.14` and uses `--ci` for the local-only lane. Privacy-first example artifact from the same example target: diff --git a/docs/proofs/current-outside-tester-send-packet.md b/docs/proofs/current-outside-tester-send-packet.md index c52a024..c2e367d 100644 --- a/docs/proofs/current-outside-tester-send-packet.md +++ b/docs/proofs/current-outside-tester-send-packet.md @@ -2,7 +2,7 @@ Use this packet to route one outside-Lambda-Curry tester through Anvil's remaining Milestone 3 proof lane. -This packet stays pinned to `@lambdacurry/anvil@0.1.0-alpha.13`. Do not swap the tester onto the floating `@alpha` tag. +This packet stays pinned to `@lambdacurry/anvil@0.1.0-alpha.14`. Do not swap the tester onto the floating `@alpha` tag. ## Three-line opener @@ -15,7 +15,7 @@ Send back whether it worked first try, the first useful fix the report pointed t ## Exact command to send ```bash -bunx @lambdacurry/anvil@0.1.0-alpha.13 audit --target . --ci --output ./anvil-audit.md +bunx @lambdacurry/anvil@0.1.0-alpha.14 audit --target . --ci --output ./anvil-audit.md ``` Send this as the only command. It assumes the tester is already in the target repo root, guarantees the saved report path, and keeps the local-only flag aligned with current public docs. @@ -27,7 +27,7 @@ Could you try one first-run Anvil audit on a real repo of yours? Paste the single command below from that repo's root; it saves `./anvil-audit.md`, stays local, and does not require an AI provider. ```bash -bunx @lambdacurry/anvil@0.1.0-alpha.13 audit --target . --ci --output ./anvil-audit.md +bunx @lambdacurry/anvil@0.1.0-alpha.14 audit --target . --ci --output ./anvil-audit.md ``` Helpful docs: @@ -39,7 +39,7 @@ What I'd love back: 1. Whether the exact command worked on the first try 2. If it did not, what failed first 3. If you changed the launcher or command, what you used instead - - If you switched to global `anvil`, keep both the pinned `bun add -g @lambdacurry/anvil@0.1.0-alpha.13` line and the `anvil audit ...` line together in `Exact command`. + - If you switched to global `anvil`, keep both the pinned `bun add -g @lambdacurry/anvil@0.1.0-alpha.14` line and the `anvil audit ...` line together in `Exact command`. 4. Whether you ran it from the repo root or somewhere else 5. The first useful fix the report pointed to, if any 6. Anything that felt confusing, too internal, or too hand-wavy @@ -47,7 +47,7 @@ What I'd love back: - If you send back the saved report path itself, keep `./anvil-audit.md`, the exact path the retained command wrote with `--output`. If you want one extra cross-check, this should print the same pinned version: -`bunx @lambdacurry/anvil@0.1.0-alpha.13 --version` +`bunx @lambdacurry/anvil@0.1.0-alpha.14 --version` If you changed launchers before the successful run, use the matching `--version` command from that same install path instead of mixing launchers in the saved packet. Do not append `anvil --version` to a `bunx` or `npx` proof packet. @@ -57,7 +57,7 @@ Count this as Milestone 3 proof only if all of these are true: - the tester is outside Lambda Curry - the tester completes a successful first run on a real repo -- the retained audit command keeps the pinned `0.1.0-alpha.13` local-only `--ci` spelling +- the retained audit command keeps the pinned `0.1.0-alpha.14` local-only `--ci` spelling - the exact command and returned artifact are retained in a saved proof packet - any rough edge found is captured as follow-up work @@ -79,7 +79,7 @@ bun run verify:first-user-proof -- docs/proofs/YYYY-MM-DD--first-user-pr ``` Run that verifier from an Anvil repo checkout or an unpacked published Anvil package root; the verifier now ships with the same proof-doc bundle. -It keys validation off the saved packet's `Pinned CLI version`, so this retained `0.1.0-alpha.13` packet can still be checked after current `main` advances to a later package version. +It keys validation off the saved packet's `Pinned CLI version`, so this retained `0.1.0-alpha.14` packet can still be checked after current `main` advances to a later package version. Historical note: the original dated retained packet for this same pinned proof lane remains at `docs/proofs/2026-05-23-alpha4-outside-tester-send-packet.md`. diff --git a/package.json b/package.json index c322302..4e1a4f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/anvil", - "version": "0.1.0-alpha.13", + "version": "0.1.0-alpha.14", "description": "AI rules + engineering guardrails audit engine for AI-assisted codebases", "keywords": [ "agents", From 9b6183027c0386a3113a1c8bd892b747761201dc Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sun, 9 Aug 2026 14:20:23 -0500 Subject: [PATCH 3/3] fix(drift): gate ancestor frames on real roots, honour every alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit findings on #44, both valid. ancestorRoots accepted every parent for four levels while its own comment claimed a package.json/.git test. On these hosts ~/projects holds many repos, so a genuinely missing path could resolve against an unrelated sibling and vanish from the report — trading a false positive for a false negative, which is the worse direction. The documented test is now the implemented one. resolvesSomewhere applied only tilde-prefixed aliases, so `@/file.ts` and `#/lib/y` were still reported as drift, and collectAliases ignored baseUrl. Every declared alias now applies, resolved against baseUrl when set. `~` is still tried as a home directory, but only after aliases. Neither cost coverage: fixture totals 30 -> 29, with 360training 19 -> 17 from the alias generalization and no repo regressing. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib/path-resolution.test.ts | 39 +++++++++++++++++++ scripts/lib/path-resolution.ts | 60 ++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/scripts/lib/path-resolution.test.ts b/scripts/lib/path-resolution.test.ts index fb3b0b9..32219fa 100644 --- a/scripts/lib/path-resolution.test.ts +++ b/scripts/lib/path-resolution.test.ts @@ -104,3 +104,42 @@ test("a genuinely absent path still resolves nowhere", () => { expect(resolvesSomewhere("apps/todo-app/TESTING.md", context)).toBe(false); expect(resolvesSomewhere("src/does-not-exist.ts", context)).toBe(false); }); + +test("an unrelated sibling repo is not an ancestor frame", () => { + // CodeRabbit on #44: accepting every parent let a genuinely missing path + // resolve against an unrelated repo further up and vanish from the report. + const parent = mkdtempSync(join(tmpdir(), "anvil-siblings-")); + created.push(parent); + mkdirSync(join(parent, "other-repo", "src"), { recursive: true }); + writeFileSync(join(parent, "other-repo", "src", "secret.ts"), ""); + const repo = join(parent, "mine"); + mkdirSync(repo, { recursive: true }); + writeFileSync(join(repo, "package.json"), JSON.stringify({ name: "mine" })); + + const context = buildResolutionContext(repo); + + // The plain parent holds no package.json/.git, so it is not a frame. + expect(resolvesSomewhere("other-repo/src/secret.ts", context)).toBe(false); +}); + +test("non-tilde aliases and baseUrl are honoured", () => { + const root = mkdtempSync(join(tmpdir(), "anvil-alias-")); + created.push(root); + mkdirSync(join(root, "src", "lib"), { recursive: true }); + writeFileSync(join(root, "src", "lib", "util.ts"), ""); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "aliased" }), + ); + writeFileSync( + join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { baseUrl: "./src", paths: { "@/*": ["./lib/*"] } }, + }), + ); + + const context = buildResolutionContext(root); + + expect(resolvesSomewhere("@/util.ts", context)).toBe(true); + expect(resolvesSomewhere("@/missing.ts", context)).toBe(false); +}); diff --git a/scripts/lib/path-resolution.ts b/scripts/lib/path-resolution.ts index e46a10a..bb49fec 100644 --- a/scripts/lib/path-resolution.ts +++ b/scripts/lib/path-resolution.ts @@ -133,7 +133,16 @@ function ancestorRoots(repoRoot: string): string[] { if (!current || current === "/" || basename(current) === "") { break; } - roots.push(current); + // Only real roots. Accepting every parent would let a genuinely missing + // path resolve against an unrelated sibling repo further up the tree and + // disappear from the report — trading a false positive for a false + // negative, which is the worse of the two here. + if ( + existsSync(join(current, "package.json")) || + existsSync(join(current, ".git")) + ) { + roots.push(current); + } current = dirname(current); } return roots; @@ -159,11 +168,19 @@ function collectAliases( for (const root of [repoRoot, ...packageRoots]) { for (const file of ["tsconfig.json", "tsconfig.base.json"]) { const config = readJson(join(root, file)); - const paths = (config?.compilerOptions as { paths?: unknown } | undefined) - ?.paths; + const compilerOptions = config?.compilerOptions as + | { paths?: unknown; baseUrl?: unknown } + | undefined; + const paths = compilerOptions?.paths; if (!paths || typeof paths !== "object") { continue; } + // Alias targets are relative to baseUrl when it is set, not to the + // tsconfig's own directory. + const base = + typeof compilerOptions?.baseUrl === "string" + ? resolve(root, compilerOptions.baseUrl) + : root; for (const [key, value] of Object.entries( paths as Record, )) { @@ -173,7 +190,7 @@ function collectAliases( const prefix = key.replace(/\*$/, ""); const targets = value .filter((v): v is string => typeof v === "string") - .map((v) => resolve(root, v.replace(/\*$/, ""))); + .map((v) => resolve(base, v.replace(/\*$/, ""))); if (prefix && targets.length > 0) { aliases.push({ prefix, targets }); } @@ -221,23 +238,28 @@ export function resolvesSomewhere( } } + // Every declared alias, not only `~` — `@/components/x` and `#/lib/y` are as + // common and were being reported as drift. + for (const alias of context.aliases) { + if (!reference.startsWith(alias.prefix)) { + continue; + } + const aliasRest = reference.slice(alias.prefix.length); + if ( + alias.targets.some( + (target) => + existsSync(join(target, aliasRest)) || + resolveWithExtensions(join(target, aliasRest)), + ) + ) { + return true; + } + } + + // Only after aliases: `~` is a home directory in some of these repos and a + // tsconfig alias in others. if (reference.startsWith("~")) { const rest = reference.slice(1).replace(/^\//, ""); - for (const alias of context.aliases) { - if (!alias.prefix.startsWith("~")) { - continue; - } - const aliasRest = reference.slice(alias.prefix.length); - if ( - alias.targets.some( - (target) => - existsSync(join(target, aliasRest)) || - resolveWithExtensions(join(target, aliasRest)), - ) - ) { - return true; - } - } if (existsSync(join(homedir(), rest))) { return true; }