diff --git a/README.md b/README.md index da10f94..a6e807f 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.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. +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.15` 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.14` +- `--version` prints `0.1.0-alpha.15` 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.14` +- **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.15` - **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.14` +- **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.15` 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 a999d01..be8873a 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.14` 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.15` 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 4de0644..0b78456 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.14` 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.15` 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 8d17086..1635ade 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.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). +> **Current alpha note:** The published `0.1.0-alpha.15` 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 9ef369a..0c98e68 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.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. +Replace `` with the specific published build you want validated. The current `0.1.0-alpha.15` 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.14` 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.15` 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.14` proof lane, that includes checking that the retained audit command keeps the packet's `--ci` spelling. +For the current pinned `0.1.0-alpha.15` 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 931e6d3..7237f62 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.14`. +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.15`. ## 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.14` 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.15` 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 f1371d9..60f5a6e 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.14` +- `--version` prints `0.1.0-alpha.15` 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.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). +> **Current alpha note:** The published `0.1.0-alpha.15` 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.14` 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.15` 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 c2e367d..bcadc5d 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.14`. Do not swap the tester onto the floating `@alpha` tag. +This packet stays pinned to `@lambdacurry/anvil@0.1.0-alpha.15`. 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.14 audit --target . --ci --output ./anvil-audit.md +bunx @lambdacurry/anvil@0.1.0-alpha.15 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.14 audit --target . --ci --output ./anvil-audit.md +bunx @lambdacurry/anvil@0.1.0-alpha.15 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.14` 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.15` 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.14 --version` +`bunx @lambdacurry/anvil@0.1.0-alpha.15 --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.14` local-only `--ci` spelling +- the retained audit command keeps the pinned `0.1.0-alpha.15` 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.14` 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.15` 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 4e1a4f8..a0ff9e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/anvil", - "version": "0.1.0-alpha.14", + "version": "0.1.0-alpha.15", "description": "AI rules + engineering guardrails audit engine for AI-assisted codebases", "keywords": [ "agents", diff --git a/scripts/audit.ts b/scripts/audit.ts index dfc016b..15de220 100644 --- a/scripts/audit.ts +++ b/scripts/audit.ts @@ -48,6 +48,7 @@ import { } from "./lib/audit-config.ts"; import { resolveProjectName } from "./lib/project-name.ts"; import { hasWhy } from "./lib/rationale.ts"; +import { upstreamAuthoredFiles } from "./lib/upstream-authorship.ts"; import { classifyLoadTier, importsRootMirror, @@ -94,6 +95,8 @@ export type RuleFile = { loadTier: LoadTier; /** Root CLAUDE.md that `@AGENTS.md`-imports rather than copying. */ importsRootMirror: boolean; + /** Exists upstream and carries no local commits — see lib/upstream-authorship.ts. */ + isUpstreamAuthored: boolean; }; export type RuleAuthorship = "governance" | "generated"; @@ -1608,22 +1611,28 @@ export function assessStageA( // every other check. const exemptFromGovernanceMetadata = inventory.canonicalGovernanceFiles.filter( - (file) => isPatternDocPath(file.relativePath) || file.importsRootMirror, + (file) => + isPatternDocPath(file.relativePath) || + file.importsRootMirror || + file.isUpstreamAuthored, ); - // If every governance file is exempt there is nothing left to measure, and an - // empty denominator must not read as 0% — that would fail a repo for having - // only the document kinds we just decided not to ask. Fall back to the full - // set, which is exactly the pre-exemption behaviour. - const datedCandidates = - exemptFromGovernanceMetadata.length === governanceCount - ? inventory.canonicalGovernanceFiles - : inventory.canonicalGovernanceFiles.filter( - (file) => !exemptFromGovernanceMetadata.includes(file), - ); + const datedCandidates = inventory.canonicalGovernanceFiles.filter( + (file) => !exemptFromGovernanceMetadata.includes(file), + ); const datedExempt = governanceCount - datedCandidates.length; + // Nothing left to ask is a pass, not a 0%. A vendored fork whose whole + // instruction surface is upstream's has no governance metadata to owe us — + // the previous fallback to the unexempted set re-failed exactly the repos the + // exemption exists for, because it predates upstream authorship being one. const datedCoverage = datedCandidates.length === 0 - ? 0 + ? // No governance files at all is a different emptiness from every file + // being exempt: the first is a missing surface, which Governance + // Surface already fails on, and the second is a surface we decided not + // to ask. + governanceCount === 0 + ? 0 + : 1 : datedCandidates.filter((f) => f.hasLastValidated).length / datedCandidates.length; @@ -1680,7 +1689,7 @@ export function assessStageA( detail: `${Math.round(datedCoverage * 100)}% of governance files include Last validated` + (datedExempt > 0 - ? ` (excludes ${datedExempt} reference/pointer doc${datedExempt === 1 ? "" : "s"})` + ? ` (excludes ${datedExempt} reference/pointer/upstream doc${datedExempt === 1 ? "" : "s"})` : ""), }); @@ -2970,9 +2979,17 @@ export function discoverRuleFiles( skipDirs?: ReadonlySet, ): RuleFile[] { const discovered: RuleFile[] = []; + // Two git calls for the repo, not two per file. + const upstreamAuthored = upstreamAuthoredFiles(projectRoot); for (const file of discoverRuleSurfaceFiles(projectRoot, skipDirs)) { - const rf = analyzeRuleFile(file.path, projectRoot, file.tool, file.format); + const rf = analyzeRuleFile( + file.path, + projectRoot, + file.tool, + file.format, + upstreamAuthored, + ); if (rf) discovered.push(rf); } @@ -3009,6 +3026,7 @@ export function analyzeRuleFile( projectRoot: string, tool: string, format: string, + upstreamAuthored: ReadonlySet = new Set(), ): RuleFile | null { try { const content = readFileSync(fullPath, "utf8"); @@ -3068,6 +3086,7 @@ export function analyzeRuleFile( content, }), importsRootMirror: importsRootMirror(content), + isUpstreamAuthored: upstreamAuthored.has(relativePath), }; } catch { return null; diff --git a/scripts/governance-metadata-scoping.test.ts b/scripts/governance-metadata-scoping.test.ts index 7e23451..d3e89cf 100644 --- a/scripts/governance-metadata-scoping.test.ts +++ b/scripts/governance-metadata-scoping.test.ts @@ -25,6 +25,7 @@ function makeRuleFile(overrides: Partial): RuleFile { fingerprint: overrides.relativePath ?? "AGENTS.md", loadTier: "always-on", importsRootMirror: false, + isUpstreamAuthored: false, ...overrides, }; } @@ -82,7 +83,7 @@ test("a reference catalog does not need governance metadata", () => { expect(check?.status).toBe("pass"); expect(check?.detail).toContain("100%"); // The exemption is stated, so a coverage figure that rose is auditable. - expect(check?.detail).toContain("excludes 7 reference/pointer docs"); + expect(check?.detail).toContain("excludes 7 reference/pointer/upstream docs"); }); test("a pointer document does not need governance metadata", () => { @@ -99,7 +100,7 @@ test("a pointer document does not need governance metadata", () => { const check = dateCheck(files); expect(check?.status).toBe("pass"); - expect(check?.detail).toContain("excludes 1 reference/pointer doc"); + expect(check?.detail).toContain("excludes 1 reference/pointer/upstream doc"); }); test("real governance docs still have to carry a date", () => { @@ -134,11 +135,10 @@ test("a repo with no exemptions reads exactly as before", () => { expect(check?.detail).toBe("50% of governance files include Last validated"); }); -test("a repo whose governance surface is entirely exempt is not failed at 0%", () => { - // The regression this nearly shipped with: exempting every file emptied the - // denominator, and an empty denominator read as 0% — failing Anvil itself, - // whose whole governance surface is bootstrap templates. With nothing left to - // measure, fall back to the full set rather than inventing a failure. +test("a repo whose governance surface is entirely exempt passes", () => { + // Nothing left to ask is a pass, not a 0%. This previously fell back to the + // unexempted set, which re-failed exactly the repos the exemption exists for + // — openclaw's 23 files are all upstream's. const files = ["a", "b", "c"].map((name) => makeRuleFile({ relativePath: `docs/patterns/${name}.md`, @@ -149,7 +149,7 @@ test("a repo whose governance surface is entirely exempt is not failed at 0%", ( const check = dateCheck(files); expect(check?.status).toBe("pass"); - expect(check?.detail).toBe("100% of governance files include Last validated"); + expect(check?.detail).toContain("excludes 3 reference/pointer/upstream docs"); }); test("bootstrap templates are governance documents, not reference catalogs", () => { @@ -168,3 +168,35 @@ test("bootstrap templates are governance documents, not reference catalogs", () expect(check?.detail).toBe("50% of governance files include Last validated"); }); + +test("an upstream-authored file is not asked for our governance metadata", () => { + // openclaw and postiz-app sat permanently red for files carrying zero lines + // of ours. Adding a date would be our first authored line in someone else's + // document, and would conflict on every merge. + const files = [ + makeRuleFile({ + relativePath: "AGENTS.md", + hasLastValidated: false, + isUpstreamAuthored: true, + }), + makeRuleFile({ relativePath: "OURS.md", hasLastValidated: true }), + ]; + + const check = dateCheck(files); + + expect(check?.status).toBe("pass"); + expect(check?.detail).toContain("excludes 1 reference/pointer/upstream doc"); +}); + +test("a file we have committed to is still ours to date", () => { + // The guard: authorship is the signal, not living in a vendored repo. + const files = [ + makeRuleFile({ + relativePath: "AGENTS.md", + hasLastValidated: false, + isUpstreamAuthored: false, + }), + ]; + + expect(dateCheck(files)?.status).toBe("fail"); +}); diff --git a/scripts/lib/upstream-authorship.ts b/scripts/lib/upstream-authorship.ts new file mode 100644 index 0000000..f8af028 --- /dev/null +++ b/scripts/lib/upstream-authorship.ts @@ -0,0 +1,82 @@ +/** + * Which instruction files are upstream's rather than ours. + * + * A vendored fork carries the upstream project's own AGENTS.md. Asking it for a + * `Last validated:` line asks us to write our first authored line into someone + * else's document, which then conflicts on every merge — and until we do, the + * repo sits permanently red on governance metadata for files we have authored + * nothing in. openclaw (0/23) and postiz-app (0/2, byte-identical to upstream's + * CLAUDE.md) are both in that state. + * + * Both signals are LOCAL. `upstream/main` only has to be *configured*, not + * reachable: `git log upstream/main..HEAD` and `git ls-tree upstream/main` read + * refs already on disk. Where no upstream remote exists the check simply does + * not fire, which is the right default for almost every repo, and keeps the + * audit offline and deterministic. + * + * Stale refs degrade toward "not exempt" — we ask for a date on a file that + * might be upstream's, rather than silently exempting one that is ours. + */ + +import { spawnSync } from "node:child_process"; + +const UPSTREAM_REF_CANDIDATES = [ + "upstream/main", + "upstream/master", + "upstream/HEAD", +] as const; + +function git(repoRoot: string, args: string[]): string | null { + const result = spawnSync("git", ["-C", repoRoot, ...args], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + if (result.status !== 0) { + return null; + } + return result.stdout ?? ""; +} + +/** The first configured upstream ref that resolves from local refs. */ +export function resolveUpstreamRef(repoRoot: string): string | null { + for (const ref of UPSTREAM_REF_CANDIDATES) { + if (git(repoRoot, ["rev-parse", "--verify", "--quiet", ref]) !== null) { + return ref; + } + } + return null; +} + +/** + * Repo-relative paths that exist in upstream and carry no local commits. + * + * Two git calls for the whole repo rather than two per file: the set of paths + * we have touched since diverging, subtracted from the set upstream ships. + */ +export function upstreamAuthoredFiles(repoRoot: string): Set { + const ref = resolveUpstreamRef(repoRoot); + if (!ref) { + return new Set(); + } + + const upstreamTree = git(repoRoot, ["ls-tree", "-r", "--name-only", ref]); + if (upstreamTree === null) { + return new Set(); + } + + const locallyTouched = new Set( + (git(repoRoot, ["log", "--name-only", "--format=", `${ref}..HEAD`]) ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + ); + + const authored = new Set(); + for (const path of upstreamTree.split("\n")) { + const trimmed = path.trim(); + if (trimmed && !locallyTouched.has(trimmed)) { + authored.add(trimmed); + } + } + return authored; +}