From a1a69dd965fbbab1ec49ed95e195be04e3a586cc Mon Sep 17 00:00:00 2001 From: letur Date: Wed, 9 Sep 2026 18:46:47 +0200 Subject: [PATCH 1/2] fix(document): rebuild a graph cached before a field existed graphAt caches a built graph on disk keyed only by commit, with no schema check, so a graph written by an older binary loads with unresolvedByFile missing and every count derived from it reads as zero. A silently wrong number is the one failure a document of derived facts cannot have. Version the record and rebuild when the version does not match, which also covers the next field rather than only this one. Claude-Session: https://claude.ai/code/session_01WW5sEutdtcbSUtgLQKBKpn --- src/coverage.ts | 2 +- src/graph.ts | 23 +++++++++++++++++++++-- test/coverage.test.ts | 3 ++- test/graph.test.ts | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/coverage.ts b/src/coverage.ts index 30b933a..a8def0c 100644 --- a/src/coverage.ts +++ b/src/coverage.ts @@ -261,7 +261,7 @@ export function computeCoverage(input: CoverageInput): Coverage { .map(([extension, n]) => ({ extension, files: n })) .sort((a, b) => b.files - a.files || a.extension.localeCompare(b.extension)), owners: owners.sort((a, b) => b.files - a.files || a.node.localeCompare(b.node)), - unresolved: files.reduce((n, f) => n + (input.graph.unresolvedByFile?.[f] ?? 0), 0), + unresolved: files.reduce((n, f) => n + (input.graph.unresolvedByFile[f] ?? 0), 0), truncated: capped > 0, }; record.verdict = coverageVerdict(record); diff --git a/src/graph.ts b/src/graph.ts index 5300ab4..87f4b04 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -32,7 +32,17 @@ export interface Edge { at: number; } +/** + * Bumped whenever a field is added to CodeGraph. `graphAt` caches a built graph + * on disk keyed only by commit, so without this a graph written by an older + * binary loads with a field missing and every count derived from it silently + * reads as zero - a wrong number rather than an error, which is exactly what a + * document that states derived facts must never do. + */ +export const GRAPH_SCHEMA = 2; + export interface CodeGraph { + schema: number; commit: string; files: string[]; symbols: Sym[]; @@ -272,14 +282,23 @@ export async function buildGraph(cwd: string, commit: string): Promise { const path = join(dir, "graph", `${commit}.json`); const cached = await readJson(path); - if (cached && cached.commit === commit) return cached; + if (cached && cached.commit === commit && cached.schema === GRAPH_SCHEMA) return cached; const g = await buildGraph(cwd, commit); await writeJson(path, g); return g; diff --git a/test/coverage.test.ts b/test/coverage.test.ts index 2316cb1..98405ff 100644 --- a/test/coverage.test.ts +++ b/test/coverage.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from "vitest"; import { computeCoverage, scopeTruncated } from "../src/coverage.ts"; -import type { CodeGraph } from "../src/graph.ts"; +import { GRAPH_SCHEMA, type CodeGraph } from "../src/graph.ts"; function graph(overrides: Partial = {}): CodeGraph { return { + schema: GRAPH_SCHEMA, commit: "deadbeef", files: [], symbols: [], diff --git a/test/graph.test.ts b/test/graph.test.ts index 0d9b58e..03a5369 100644 --- a/test/graph.test.ts +++ b/test/graph.test.ts @@ -4,7 +4,7 @@ import { promisify } from "node:util"; import { mkdtemp, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildGraph, impact, architecture, capFiles } from "../src/graph.ts"; +import { buildGraph, graphAt, impact, architecture, capFiles, GRAPH_SCHEMA } from "../src/graph.ts"; import { lineChanges } from "../src/git.ts"; const execFileP = promisify(execFile); @@ -24,6 +24,40 @@ async function repo() { return { dir, git }; } +describe("graphAt", () => { + it("rebuilds a graph cached by an older binary instead of trusting its shape", async () => { + const { dir, git } = await repo(); + await writeFile( + join(dir, "src", "a.ts"), + `export function run() {\n return fetch("/x");\n}\n`, + ); + await git("add", "."); + await git("commit", "-q", "-m", "base"); + const commit = (await git("rev-parse", "HEAD")).stdout.trim(); + // what a pre-upgrade thurview left on disk: right commit, missing fields + const cache = join(dir, ".cache", "graph", `${commit}.json`); + await mkdir(join(dir, ".cache", "graph"), { recursive: true }); + await writeFile( + cache, + JSON.stringify({ + commit, + files: [], + symbols: [], + edges: [], + unresolved: 0, + truncated: false, + }), + ); + const g = await graphAt(dir, commit, join(dir, ".cache")); + // trusting the stale record would report zero unresolved references, which is + // a wrong number rather than an error + expect(g.schema).toBe(GRAPH_SCHEMA); + expect(g.unresolvedByFile).toEqual({ "src/a.ts": 1 }); + const again = await graphAt(dir, commit, join(dir, ".cache")); + expect(again.unresolvedByFile).toEqual({ "src/a.ts": 1 }); + }); +}); + describe("buildGraph", () => { it("counts a call to an undefined name as unresolved instead of dropping it", async () => { const { dir, git } = await repo(); From 167eb8ab036e81cd83fd53a9d6cde958eb604267 Mon Sep 17 00:00:00 2001 From: letur Date: Wed, 9 Sep 2026 18:57:06 +0200 Subject: [PATCH 2/2] no-mistakes: apply CI fixes --- cog.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cog.toml b/cog.toml index 4ecf661..906c7fe 100644 --- a/cog.toml +++ b/cog.toml @@ -7,7 +7,7 @@ branch_whitelist = ["main", "v*.x"] ignore_merge_commits = true # List of valid commit scopes -scopes = ["cli", "server", "ui", "document", "map", "theme", "skill", "docs", "deps", "config", "ci"] +scopes = ["cli", "server", "ui", "document", "map", "graph", "theme", "skill", "docs", "deps", "config", "ci"] # Tag prefix for semantic versioning tag_prefix = "v"