@mrciphersmith/keryx 0.2.80 → 0.2.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ import type {
9
9
  SymbolNode,
10
10
  WikiPageNode,
11
11
  } from "./types";
12
+ import { TYPE_ONLY_IMPORT_KIND } from "./types";
12
13
  import { resolveGraphTarget } from "./target";
13
14
 
14
15
  export async function loadGraph(projectRoot: string): Promise<GraphData> {
@@ -116,7 +117,19 @@ export function getCycles(graph: GraphData): string[][] {
116
117
  // load-order cycle this query answers (P1, flow 140). Excluding it here
117
118
  // — rather than reclassifying `edge.kind` — leaves orphans/affected
118
119
  // untouched (AC5): both still treat the edge as a normal import.
119
- if (edge.kind !== "imports" || edge.importKind === "dynamic-import") {
120
+ //
121
+ // A `type-only` edge (AFC-11, flow 234) is excluded for the same reason:
122
+ // `import type`/`export type … from`/an all-`type`-specifier import is
123
+ // erased by the compiler, so it never runs at module-load time and a
124
+ // cycle closed only through such edges is not a real runtime deadlock.
125
+ // Same non-reclassification rule applies — `edge.kind` stays "imports"
126
+ // so getOrphans/getAffected/computeAffected still see it as a real
127
+ // dependency for impact analysis; only this load-order adjacency drops it.
128
+ if (
129
+ edge.kind !== "imports" ||
130
+ edge.importKind === "dynamic-import" ||
131
+ edge.importKind === TYPE_ONLY_IMPORT_KIND
132
+ ) {
120
133
  continue;
121
134
  }
122
135
  adjacency.get(edge.from)?.push(edge.to);
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { expect, test } from "bun:test";
5
- import { createGdgraphService } from "./service";
5
+ import { createGdgraphService, UnknownGraphTargetError } from "./service";
6
6
 
7
7
  async function makeProject(): Promise<string> {
8
8
  const root = await mkdtemp(path.join(tmpdir(), "keryx-svc-"));
@@ -44,6 +44,36 @@ test("AC5.3/T-1 — service.repomap writes artifacts/repomap.md deterministicall
44
44
  }
45
45
  });
46
46
 
47
+ // AFC-10 (flow 234, phase 2): before this task, `service.affected` for a
48
+ // target the graph never indexed and a target the graph indexed but found no
49
+ // edges for returned byte-identical shapes — `{dependencies: [], dependents:
50
+ // [], ranked: []}` — differing only in the echoed-back `target` string.
51
+ // Measured directly against this exact fixture: an unknown path
52
+ // ("src/does-not-exist.ts") and a real, indexed, edge-less leaf file
53
+ // produced the same `{dependencies, dependents, ranked}`. That is the same
54
+ // defect class phase 1 closed at eight other sites — a failure rendered as a
55
+ // legitimate empty success — so an unknown target must now be reported
56
+ // distinguishably instead of silently resolving to an empty result.
57
+ test("AFC-10 — service.affected on a target the graph never indexed throws UnknownGraphTargetError, distinct from an indexed-but-edgeless target", async () => {
58
+ const root = await makeProject();
59
+ try {
60
+ await writeFile(path.join(root, "src", "isolated.ts"), "export const iso = 1;\n");
61
+ const service = createGdgraphService();
62
+ await service.build(root);
63
+
64
+ // Indexed, legitimately no edges at all: a real success, not an error.
65
+ const indexedNoEdges = await service.affected(root, "src/isolated.ts");
66
+ expect(indexedNoEdges.dependencies).toEqual([]);
67
+ expect(indexedNoEdges.dependents).toEqual([]);
68
+
69
+ // Never indexed: must be distinguishable from the above, not the same
70
+ // `{dependencies: [], dependents: []}` shape under a different `target`.
71
+ await expect(service.affected(root, "src/does-not-exist.ts")).rejects.toThrow(UnknownGraphTargetError);
72
+ } finally {
73
+ await rm(root, { recursive: true, force: true });
74
+ }
75
+ });
76
+
47
77
  test("service.query returns cycles + orphans over the built graph", async () => {
48
78
  const root = await makeProject();
49
79
  try {
@@ -20,6 +20,29 @@ export interface GdgraphService {
20
20
  query(cwd: string, q: "cycles" | "orphans"): Promise<string[] | string[][]>;
21
21
  }
22
22
 
23
+ // AFC-10 (flow 234, phase 2, frozen AC3): "an unknown target differs from
24
+ // indexed/no edges." Before this, `affected()` for a target the graph never
25
+ // heard of resolved (via `target.ts`'s `resolveGraphTarget`, by contract:
26
+ // "Returns the normalized target unchanged when nothing matches") to the
27
+ // exact same `{dependencies: [], dependents: [], ranked: []}` shape as a
28
+ // target the graph DID index but legitimately has no edges for — a failure
29
+ // rendered as an empty success, the same defect class phase 1 closed at
30
+ // eight other sites. `target.ts`/`affected.ts` are owned by a concurrent
31
+ // agent this task must not edit, so the distinguishing check lives here: the
32
+ // facade already loads the graph, so it can cheaply confirm the RESOLVED
33
+ // target is a real node before handing back a result callers would
34
+ // otherwise read as a legitimate (if boring) answer.
35
+ export class UnknownGraphTargetError extends Error {
36
+ constructor(public readonly target: string) {
37
+ super(
38
+ `gdgraph: "${target}" is not a node in the built graph (never indexed, or the ` +
39
+ `path/symbol does not exist) — this is not the same as an indexed target with ` +
40
+ `zero edges. Run \`keryx gdgraph build\` if the file is new, or double-check the path.`,
41
+ );
42
+ this.name = "UnknownGraphTargetError";
43
+ }
44
+ }
45
+
23
46
  export function createGdgraphService(): GdgraphService {
24
47
  return {
25
48
  async build(cwd) {
@@ -34,7 +57,16 @@ export function createGdgraphService(): GdgraphService {
34
57
  const config = await loadGdgraphConfig(cwd);
35
58
  const graph = await loadGraph(cwd);
36
59
  const depth = options.depth ?? config.affected.defaultDepth;
37
- return computeAffected(graph, target, { ...options, depth });
60
+ const result = computeAffected(graph, target, { ...options, depth });
61
+ // `result.target` is the RESOLVED target (exact/suffix match), or —
62
+ // per target.ts's own contract — the normalized input unchanged when
63
+ // nothing matched. Membership in the loaded node set is exactly the
64
+ // signal that distinguishes the two.
65
+ const isKnownNode = graph.nodes.some((node) => node.path === result.target);
66
+ if (!isKnownNode) {
67
+ throw new UnknownGraphTargetError(target);
68
+ }
69
+ return result;
38
70
  },
39
71
 
40
72
  async repomap(cwd, options = {}) {
@@ -0,0 +1,208 @@
1
+ // AFC-10 (flow 234, phase 2): the graph "staleness" signal must actually track
2
+ // the five triggers the frozen AC3 names — new commit, untracked, delete,
3
+ // rename, config change — and a git failure must never collapse into "fresh".
4
+ //
5
+ // Before this task, `graphMaybeStale` compared `.git/HEAD`'s own mtime to
6
+ // `nodes.jsonl`'s. That misses every trigger here: `.git/HEAD` is a symbolic
7
+ // ref ("ref: refs/heads/<branch>") whose CONTENT (and often mtime) does not
8
+ // change on an ordinary commit to the current branch, and it never reflects
9
+ // untracked/deleted/renamed working-tree state or a config-file edit at all.
10
+ // These tests build a real git fixture per trigger (never the project repo
11
+ // itself) and assert against `checkGraphStaleness`'s structured result.
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { afterEach, beforeEach, expect, test } from "bun:test";
18
+ import { checkGraphStaleness, graphMaybeStale } from "./staleness";
19
+ import { recordProvenance } from "../sync/provenance";
20
+
21
+ let root: string;
22
+
23
+ function git(cwd: string, args: string[]): void {
24
+ execFileSync("git", args, { cwd, stdio: "ignore" });
25
+ }
26
+
27
+ /**
28
+ * A minimal built-graph fixture: a real git repo with one committed source
29
+ * file, a `nodes.jsonl` "graph storage" file, and recorded build provenance
30
+ * pointing at the current HEAD — i.e. the state right after a clean
31
+ * `keryx gdgraph build` with nothing having moved since.
32
+ */
33
+ async function makeBuiltFixture(): Promise<string> {
34
+ const dir = await mkdtemp(path.join(tmpdir(), "keryx-staleness-"));
35
+ git(dir, ["init", "-q"]);
36
+ git(dir, ["config", "user.email", "test@test.com"]);
37
+ git(dir, ["config", "user.name", "test"]);
38
+ await mkdir(path.join(dir, "src"), { recursive: true });
39
+ await mkdir(path.join(dir, ".metaproject", "data", "gdgraph", "storage"), { recursive: true });
40
+ await writeFile(path.join(dir, "src", "a.ts"), "export const a = 1;\n");
41
+ await writeFile(
42
+ path.join(dir, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl"),
43
+ '{"id":"src/a.ts","kind":"file","path":"src/a.ts","language":"typescript"}\n',
44
+ );
45
+ git(dir, ["add", "-A"]);
46
+ git(dir, ["commit", "-q", "-m", "initial build fixture"]);
47
+ // `recordProvenance` reads `git rev-parse HEAD`, which fails with zero
48
+ // commits — it must run AFTER the first commit exists, or it silently
49
+ // no-ops (by design: "no-op outside a git repo", which a HEAD-less repo
50
+ // with no commits yet also satisfies). It is deliberately left UNCOMMITTED
51
+ // here, matching the real project's own state (`.provenance.json` sits
52
+ // modified/untracked in the working tree right after every build) — the
53
+ // staleness check must treat that as normal build residue, not a trigger.
54
+ await recordProvenance(dir, "gdgraph", new Date().toISOString());
55
+ // Ensure nodes.jsonl's mtime is not accidentally newer than a config file
56
+ // written a moment later in the same test (mtime resolution on some
57
+ // filesystems is coarse) by nudging it slightly into the past.
58
+ const past = new Date(Date.now() - 5000);
59
+ await utimes(path.join(dir, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl"), past, past);
60
+ return dir;
61
+ }
62
+
63
+ beforeEach(async () => {
64
+ root = await makeBuiltFixture();
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await rm(root, { recursive: true, force: true });
69
+ });
70
+
71
+ test("AFC-10 control — a clean tree right after build reports fresh", async () => {
72
+ const result = await checkGraphStaleness(root);
73
+ expect(result.status).toBe("fresh");
74
+ expect(await graphMaybeStale(root)).toBe(false);
75
+ });
76
+
77
+ test("AFC-10 trigger 1/5 — a new commit since the build invalidates the snapshot", async () => {
78
+ await writeFile(path.join(root, "src", "b.ts"), "export const b = 1;\n");
79
+ git(root, ["add", "-A"]);
80
+ git(root, ["commit", "-q", "-m", "a new commit after the graph was built"]);
81
+
82
+ const result = await checkGraphStaleness(root);
83
+ expect(result.status).not.toBe("fresh");
84
+ expect(await graphMaybeStale(root)).toBe(true);
85
+ });
86
+
87
+ test("AFC-10 trigger 2/5 — an untracked file invalidates the snapshot", async () => {
88
+ await writeFile(path.join(root, "src", "untracked.ts"), "export const u = 1;\n");
89
+
90
+ const result = await checkGraphStaleness(root);
91
+ expect(result.status).not.toBe("fresh");
92
+ expect(await graphMaybeStale(root)).toBe(true);
93
+ });
94
+
95
+ test("AFC-10 trigger 3/5 — a deleted tracked file invalidates the snapshot", async () => {
96
+ await rm(path.join(root, "src", "a.ts"));
97
+
98
+ const result = await checkGraphStaleness(root);
99
+ expect(result.status).not.toBe("fresh");
100
+ expect(await graphMaybeStale(root)).toBe(true);
101
+ });
102
+
103
+ test("AFC-10 trigger 4/5 — a rename invalidates the snapshot even though file count and content are unchanged", async () => {
104
+ // The subtle case: `git mv` keeps the file count and byte content identical
105
+ // to the fixture's committed state — only the path moved.
106
+ git(root, ["mv", "src/a.ts", "src/a-renamed.ts"]);
107
+
108
+ const result = await checkGraphStaleness(root);
109
+ expect(result.status).not.toBe("fresh");
110
+ expect(await graphMaybeStale(root)).toBe(true);
111
+ });
112
+
113
+ test("AFC-10 trigger 5/5 — a gdgraph.config.json change invalidates the snapshot", async () => {
114
+ await writeFile(
115
+ path.join(root, ".metaproject", "gdgraph.config.json"),
116
+ JSON.stringify({ affected: { defaultDepth: 2 } }) + "\n",
117
+ );
118
+
119
+ const result = await checkGraphStaleness(root);
120
+ expect(result.status).not.toBe("fresh");
121
+ expect(await graphMaybeStale(root)).toBe(true);
122
+ });
123
+
124
+ test("AFC-10 group 3 — a git failure is reported as unknown, never as fresh", async () => {
125
+ // Delete the .git directory entirely so every git invocation the check
126
+ // makes fails (spawn succeeds, git exits non-zero / "not a git repository").
127
+ await rm(path.join(root, ".git"), { recursive: true, force: true });
128
+
129
+ const result = await checkGraphStaleness(root);
130
+ expect(result.status).toBe("unknown");
131
+ expect(result.reasons.length).toBeGreaterThan(0);
132
+ // The boolean back-compat wrapper must still never read as "fresh" (false)
133
+ // on a git failure — "unknown" collapses to "treat as maybe-stale", not to
134
+ // the safe-looking "false" the old mtime-diff check silently returned.
135
+ expect(await graphMaybeStale(root)).toBe(true);
136
+ });
137
+
138
+ test("AFC-10 — graph never built at all is reported as stale (not fresh), not a git failure", async () => {
139
+ await rm(path.join(root, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl"));
140
+
141
+ const result = await checkGraphStaleness(root);
142
+ expect(result.status).toBe("stale");
143
+ expect(await graphMaybeStale(root)).toBe(true);
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // T19 finding 3 (flow 234 review) — `git status --porcelain` prints paths
148
+ // relative to the REPOSITORY ROOT, not to the directory git was invoked in.
149
+ // The `.metaproject/` exclusion in `categorizeStatusLines` compared a bare
150
+ // `.metaproject/` prefix against those repo-root-relative paths, which only
151
+ // ever matches when the project root IS the git root. In a monorepo where the
152
+ // project root sits below the git root, the prefix never matches, so the
153
+ // graph's own build residue (`.provenance.json`, `artifacts/*` — expected to
154
+ // sit modified/untracked right after every build) reads as a real untracked
155
+ // file and the graph reports stale immediately after every clean build.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ test("AFC-10 / T19 finding 3 — at the git root, the .metaproject exclusion behaves exactly as before", async () => {
159
+ // `root` (from `makeBuiltFixture`) already IS the git root — this is the
160
+ // control case the fix must not change. `--show-prefix` there resolves to
161
+ // "" and the fix must produce the identical "fresh" result as pre-fix.
162
+ const result = await checkGraphStaleness(root);
163
+ expect(result.status).toBe("fresh");
164
+ });
165
+
166
+ test("T19 finding 3 — a project root below the git root still excludes its own .metaproject build residue", async () => {
167
+ const gitRoot = await mkdtemp(path.join(tmpdir(), "keryx-staleness-monorepo-"));
168
+ try {
169
+ git(gitRoot, ["init", "-q"]);
170
+ git(gitRoot, ["config", "user.email", "test@test.com"]);
171
+ git(gitRoot, ["config", "user.name", "test"]);
172
+
173
+ const projectDir = path.join(gitRoot, "packages", "proj");
174
+ await mkdir(path.join(projectDir, "src"), { recursive: true });
175
+ await mkdir(path.join(projectDir, ".metaproject", "data", "gdgraph", "storage"), { recursive: true });
176
+ await writeFile(path.join(projectDir, "src", "a.ts"), "export const a = 1;\n");
177
+ await writeFile(
178
+ path.join(projectDir, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl"),
179
+ '{"id":"src/a.ts","kind":"file","path":"src/a.ts","language":"typescript"}\n',
180
+ );
181
+ git(gitRoot, ["add", "-A"]);
182
+ git(gitRoot, ["commit", "-q", "-m", "initial monorepo build fixture"]);
183
+
184
+ // Mirrors `makeBuiltFixture`: `recordProvenance` leaves `.provenance.json`
185
+ // UNCOMMITTED, exactly like a real `keryx gdgraph build` does — this is
186
+ // the graph's own bookkeeping the exclusion exists to skip, and (per the
187
+ // reviewer's repro) the ONLY dirty entry in the working tree here.
188
+ await recordProvenance(projectDir, "gdgraph", new Date().toISOString());
189
+ const past = new Date(Date.now() - 5000);
190
+ await utimes(
191
+ path.join(projectDir, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl"),
192
+ past,
193
+ past,
194
+ );
195
+
196
+ const result = await checkGraphStaleness(projectDir);
197
+ // Before the fix: `git status --porcelain` (run with cwd=projectDir)
198
+ // prints "packages/proj/.metaproject/data/gdgraph/.provenance.json",
199
+ // which does not start with the bare ".metaproject/" prefix the old
200
+ // check compared against — so it read as a real untracked file and this
201
+ // was "stale" on a repo with nothing but the graph's own residue dirty.
202
+ expect(result.status).toBe("fresh");
203
+ expect(result.reasons).toEqual([]);
204
+ expect(await graphMaybeStale(projectDir)).toBe(false);
205
+ } finally {
206
+ await rm(gitRoot, { recursive: true, force: true });
207
+ }
208
+ });
@@ -1,20 +1,202 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { gitCmd, gitHead, readProvenance } from "../sync/provenance";
3
4
 
4
- // Cheap, low-noise graph-staleness signal: the graph is "maybe stale" when the
5
- // repo's `.git/HEAD` (which changes on commit / branch switch) is newer than the
6
- // built graph storage. Unlike "uncommitted changes", this does NOT fire on every
7
- // working-tree edit during active development — only after the repo state moved
8
- // since the last build. Best-effort: any error ⇒ not stale (never warns wrongly).
9
- export async function graphMaybeStale(cwd: string): Promise<boolean> {
10
- const nodes = path.join(cwd, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl");
11
- const head = path.join(cwd, ".git", "HEAD");
12
- try {
13
- const [nodesStat, headStat] = await Promise.all([stat(nodes), stat(head)]);
14
- return headStat.mtimeMs > nodesStat.mtimeMs;
15
- } catch {
16
- return false;
5
+ // AFC-10 (flow 234, phase 2, frozen AC3): "a new commit, untracked, delete,
6
+ // rename and config change invalidate the snapshot; an unknown target differs
7
+ // from indexed/no-edges; a git error never becomes fresh."
8
+ //
9
+ // The PREVIOUS implementation of this module compared `.git/HEAD`'s mtime to
10
+ // the built graph's `nodes.jsonl` mtime. That is broken for every trigger AC3
11
+ // names: `.git/HEAD` is a symbolic ref ("ref: refs/heads/<branch>") whose
12
+ // CONTENT does not change on an ordinary commit to the current branch (only
13
+ // the ref file it points at does), so its mtime frequently does not advance
14
+ // either — measured directly against a real fixture in `staleness.test.ts`,
15
+ // a fresh commit left the old check reporting "not stale". It also never
16
+ // looked at working-tree status at all, so an untracked file, a deleted
17
+ // file, or a rename (which leaves file COUNT and CONTENT unchanged — the
18
+ // subtle case) were invisible to it. And on any error it returned `false`
19
+ // ("not stale"), i.e. a git failure silently read as fresh.
20
+ //
21
+ // This version checks each trigger against real signals:
22
+ // - new commit: recorded build provenance's commit vs current
23
+ // `git rev-parse HEAD` (falls back to
24
+ // `.git/logs/HEAD`'s mtime — which DOES advance
25
+ // on every commit/checkout/branch-switch, unlike
26
+ // `.git/HEAD`'s own — when no provenance was
27
+ // recorded for this build).
28
+ // - untracked/delete/rename: `git status --porcelain=v1`, categorized by
29
+ // status letter. A plain in-place content edit
30
+ // (` M`/`M `) is deliberately NOT a trigger here —
31
+ // the module contract (`modules/gdgraph.md`,
32
+ // "Freshness & Refresh") only promises the
33
+ // file-level graph goes stale when the file SET
34
+ // moves; an edit with an unchanged import set
35
+ // leaves the file-level graph correct.
36
+ // - config change: `.metaproject/gdgraph.config.json`'s mtime vs
37
+ // `nodes.jsonl`'s. No separate baseline write is
38
+ // needed for this: the config file only *has* a
39
+ // newer mtime than the graph when someone touched
40
+ // it after the build ran.
41
+ // - git failure: ANY git invocation here failing (spawn error,
42
+ // non-zero exit, e.g. not a git repo) short-
43
+ // circuits the result to `status: "unknown"` —
44
+ // it is never allowed to fall through to "fresh".
45
+ export type StalenessStatus = "fresh" | "stale" | "unknown";
46
+
47
+ export interface StalenessCheck {
48
+ status: StalenessStatus;
49
+ // Human-readable reasons, one per trigger/failure that fired. Empty only
50
+ // when `status === "fresh"`.
51
+ reasons: string[];
52
+ }
53
+
54
+ function nodesJsonlPath(cwd: string): string {
55
+ return path.join(cwd, ".metaproject", "data", "gdgraph", "storage", "nodes.jsonl");
56
+ }
57
+
58
+ function gdgraphConfigPath(cwd: string): string {
59
+ return path.join(cwd, ".metaproject", "gdgraph.config.json");
60
+ }
61
+
62
+ // Categorize `git status --porcelain=v1` lines. Each line is exactly two
63
+ // status characters (index status, worktree status) followed by a space and
64
+ // the path (renames add " -> newPath"). A pure content modify (` M`/`M `/`MM`)
65
+ // intentionally does not set any of these three — see the module doc above.
66
+ //
67
+ // Lines under `.metaproject/` are skipped entirely: that tree holds gdgraph's
68
+ // OWN generated bookkeeping (`.provenance.json`, `artifacts/*`), which the
69
+ // module contract says is expected to sit modified/untracked in the working
70
+ // tree right after every build ("a rebuild leaves those files modified after
71
+ // the commit") — that is normal build residue, not evidence the SOURCE tree
72
+ // moved, and treating it as a trigger would make every build immediately
73
+ // report itself as stale. The config file is checked separately, by mtime.
74
+ //
75
+ // T19 finding 3 (flow 234 review): `git status --porcelain` paths are always
76
+ // relative to the REPOSITORY ROOT, not to the directory git was invoked in
77
+ // (`cwd` here, which is the *project* root and may sit below the git root in
78
+ // a monorepo). A bare ".metaproject/" prefix only ever matches when the
79
+ // project root IS the git root; `rootPrefix` (from `git rev-parse
80
+ // --show-prefix`, empty at the git root) is prepended so the same exclusion
81
+ // matches at both.
82
+ function categorizeStatusLines(
83
+ porcelain: string,
84
+ rootPrefix: string,
85
+ ): { added: boolean; deleted: boolean; renamed: boolean } {
86
+ const lines = porcelain.split("\n").filter((line) => line.length >= 2);
87
+ const metaprojectPrefix = `${rootPrefix}.metaproject/`;
88
+ let added = false;
89
+ let deleted = false;
90
+ let renamed = false;
91
+ for (const line of lines) {
92
+ const indexStatus = line[0];
93
+ const worktreeStatus = line[1];
94
+ const rawPath = line.slice(3);
95
+ // For a rename, porcelain prints "old -> new"; check the side(s) that
96
+ // matter for the metaproject exclusion (either is enough to skip a pure
97
+ // internal-bookkeeping rename, which does not occur in practice anyway).
98
+ const isMetaprojectPath = rawPath
99
+ .split(" -> ")
100
+ .some((candidate) => candidate.replace(/^"|"$/g, "").startsWith(metaprojectPrefix));
101
+ if (isMetaprojectPath) {
102
+ continue;
103
+ }
104
+ if (indexStatus === "?" || worktreeStatus === "?" || indexStatus === "A") {
105
+ added = true;
106
+ }
107
+ if (indexStatus === "D" || worktreeStatus === "D") {
108
+ deleted = true;
109
+ }
110
+ if (indexStatus === "R" || worktreeStatus === "R") {
111
+ renamed = true;
112
+ }
113
+ }
114
+ return { added, deleted, renamed };
115
+ }
116
+
117
+ /**
118
+ * The structured staleness check (AFC-10). Never throws — a git failure is
119
+ * reported as `status: "unknown"` with a reason, not thrown and not "fresh".
120
+ */
121
+ export async function checkGraphStaleness(cwd: string): Promise<StalenessCheck> {
122
+ const nodesStat = await stat(nodesJsonlPath(cwd)).catch(() => null);
123
+ if (!nodesStat) {
124
+ return { status: "stale", reasons: ["graph has not been built yet (no nodes.jsonl)"] };
17
125
  }
126
+
127
+ const reasons: string[] = [];
128
+ let gitFailed = false;
129
+
130
+ // --- new commit -----------------------------------------------------------
131
+ const head = await gitHead(cwd);
132
+ if (head === null) {
133
+ gitFailed = true;
134
+ reasons.push("git rev-parse HEAD failed (not a git repository, or git is unavailable)");
135
+ } else {
136
+ const provenance = await readProvenance(cwd, "gdgraph");
137
+ if (provenance) {
138
+ if (provenance.commit !== head.commit) {
139
+ reasons.push(
140
+ `HEAD moved since the graph was built (built at ${provenance.commit.slice(0, 12)}, now ${head.commit.slice(0, 12)})`,
141
+ );
142
+ }
143
+ } else {
144
+ // No recorded build provenance for this graph. Fall back to the
145
+ // reflog's mtime: unlike `.git/HEAD` (a symbolic ref whose content is
146
+ // usually just "ref: refs/heads/<branch>" and does not change on an
147
+ // ordinary commit), `.git/logs/HEAD` gets a new entry — and therefore a
148
+ // fresh mtime — on every commit, checkout, and branch switch.
149
+ const logsHeadStat = await stat(path.join(cwd, ".git", "logs", "HEAD")).catch(() => null);
150
+ if (logsHeadStat && logsHeadStat.mtimeMs > nodesStat.mtimeMs) {
151
+ reasons.push(
152
+ "repo HEAD moved since the graph was built (no build provenance recorded; inferred from .git/logs/HEAD)",
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ // --- untracked / deleted / renamed ----------------------------------------
159
+ const porcelain = await gitCmd(cwd, ["status", "--porcelain=v1"]);
160
+ if (porcelain === null) {
161
+ gitFailed = true;
162
+ reasons.push("git status failed");
163
+ } else {
164
+ // T19 finding 3: `--show-prefix` is the project root's path relative to
165
+ // the git root (empty string AT the git root, "packages/proj/" style
166
+ // below it) — exactly the prefix `git status --porcelain`'s repo-root-
167
+ // relative paths need for the `.metaproject/` exclusion to match
168
+ // regardless of where the project root sits. A failed/non-git lookup
169
+ // (already caught above by the HEAD/status checks) falls back to "",
170
+ // preserving today's at-the-root behavior rather than under-excluding.
171
+ const rootPrefix = (await gitCmd(cwd, ["rev-parse", "--show-prefix"])) ?? "";
172
+ const { added, deleted, renamed } = categorizeStatusLines(porcelain, rootPrefix);
173
+ if (added) reasons.push("an untracked or newly added file exists in the working tree");
174
+ if (deleted) reasons.push("a tracked file was deleted in the working tree");
175
+ if (renamed) reasons.push("a file was renamed (staged) in the working tree");
176
+ }
177
+
178
+ // --- config changed ---------------------------------------------------------
179
+ const configStat = await stat(gdgraphConfigPath(cwd)).catch(() => null);
180
+ if (configStat && configStat.mtimeMs > nodesStat.mtimeMs) {
181
+ reasons.push("gdgraph.config.json changed since the graph was built");
182
+ }
183
+
184
+ if (gitFailed) {
185
+ // A git failure is reported as its own status — never silently
186
+ // downgraded to "fresh", and never conflated with a confirmed "stale"
187
+ // either, since we could not fully verify either way.
188
+ return { status: "unknown", reasons };
189
+ }
190
+ return reasons.length > 0 ? { status: "stale", reasons } : { status: "fresh", reasons: [] };
191
+ }
192
+
193
+ // Back-compat boolean surface for existing callers (`commands/gdgraph.ts`,
194
+ // `wiki/staleness.ts`): "not demonstrably fresh" -> true. `"stale"` and
195
+ // `"unknown"` both map to `true` so a git failure can never read as `false`
196
+ // ("fresh") the way the old mtime-diff implementation did.
197
+ export async function graphMaybeStale(cwd: string): Promise<boolean> {
198
+ const result = await checkGraphStaleness(cwd);
199
+ return result.status !== "fresh";
18
200
  }
19
201
 
20
202
  export const STALE_NOTE = "note: repo moved since the last graph build — `keryx gdgraph build` to refresh.";
@@ -1,5 +1,13 @@
1
1
  import { expect, test } from "bun:test";
2
- import { isTreesitterEnabled, setTreesitterEnabled, TREESITTER_CAPABILITY } from "./symbols-capability";
2
+ import {
3
+ isTreesitterEnabled,
4
+ requireSymbols,
5
+ setTreesitterEnabled,
6
+ SymbolsUnavailableError,
7
+ TREESITTER_CAPABILITY,
8
+ type SymbolsLayerCarrier,
9
+ } from "./symbols-capability";
10
+ import type { GrammarDiagnosis } from "./treesitter/adapter";
3
11
 
4
12
  test("enable adds the capability entry to an empty manifest", () => {
5
13
  const next = setTreesitterEnabled({}, true);
@@ -39,3 +47,92 @@ test("preserves other gdgraph capabilities and other modules", () => {
39
47
  expect(next.modules.security).toEqual({ enabled: true });
40
48
  expect(next.schemaVersion).toBe(1);
41
49
  });
50
+
51
+ // --- AFC-13 / AC5 requirement 2: requireSymbols gives a typed, actionable
52
+ // error — never undefined, never an empty result, never a generic Error. ---
53
+
54
+ test("AC5.req2 — requireSymbols returns graph.symbols when the symbol layer is present (including an empty array — the layer RAN, it just found nothing)", () => {
55
+ const withSymbols = { symbols: [{ id: "a" }] };
56
+ expect(requireSymbols(withSymbols)).toEqual(withSymbols.symbols);
57
+
58
+ const ranButEmpty = { symbols: [] };
59
+ expect(requireSymbols(ranButEmpty)).toEqual([]);
60
+ });
61
+
62
+ test("AC5.req2 — requireSymbols throws SymbolsUnavailableError (not undefined, not an empty result, not a generic Error) when the symbol layer never ran", () => {
63
+ // A `GraphData`-shaped object with no `symbols` key — exactly what the
64
+ // graph looks like when the symbol layer never ran. The cast (rather than a
65
+ // bare literal) is only to satisfy TS's weak-object-literal check, which
66
+ // otherwise flags "no properties in common" for a fresh literal compared
67
+ // against a type with a single optional property; a real `GraphData` value
68
+ // (a named type, not a fresh literal) never triggers this.
69
+ const graphWithNoLayer = { nodes: [], edges: [] } as unknown as SymbolsLayerCarrier;
70
+ expect(() => requireSymbols(graphWithNoLayer)).toThrow(SymbolsUnavailableError);
71
+
72
+ let caught: unknown;
73
+ try {
74
+ requireSymbols(graphWithNoLayer);
75
+ } catch (err) {
76
+ caught = err;
77
+ }
78
+ expect(caught).toBeInstanceOf(SymbolsUnavailableError);
79
+ expect(caught).toBeInstanceOf(Error);
80
+ const typed = caught as SymbolsUnavailableError;
81
+ // Typed and actionable: a stable `code`, a non-empty `remedy`, a non-empty
82
+ // `message` — not a bare string thrown, not `err.message === ""`.
83
+ expect(typed.code).toBe("no-symbol-layer");
84
+ expect(typeof typed.remedy).toBe("string");
85
+ expect(typed.remedy.length).toBeGreaterThan(0);
86
+ expect(typed.message.length).toBeGreaterThan(0);
87
+ });
88
+
89
+ // --- AFC-13 / AC5 requirement 3: missing vs incompatible must be
90
+ // distinguishable, not collapsed into one outcome. ---
91
+
92
+ test("AC5.req3 — requireSymbols reports grammar-missing when diagnoses say the grammar never resolved", () => {
93
+ const diagnoses: GrammarDiagnosis[] = [
94
+ { language: "typescript", status: "missing", reason: 'grammar asset "tree-sitter-typescript" is not resolved' },
95
+ ];
96
+ let caught: SymbolsUnavailableError | undefined;
97
+ try {
98
+ requireSymbols({}, diagnoses);
99
+ } catch (err) {
100
+ caught = err as SymbolsUnavailableError;
101
+ }
102
+ expect(caught?.code).toBe("grammar-missing");
103
+ expect(caught?.message).toContain("typescript");
104
+ });
105
+
106
+ test("AC5.req3 — requireSymbols reports grammar-incompatible when diagnoses say the grammar resolved but failed to load (ABI mismatch), and this is a DIFFERENT code/message/remedy than grammar-missing", () => {
107
+ const incompatible: GrammarDiagnosis[] = [
108
+ {
109
+ language: "typescript",
110
+ status: "incompatible",
111
+ reason: 'grammar at "/cache/tree-sitter-typescript" failed to load (likely an ABI/version mismatch): Incompatible language version 13. Expected minimum 14',
112
+ },
113
+ ];
114
+ let caught: SymbolsUnavailableError | undefined;
115
+ try {
116
+ requireSymbols({}, incompatible);
117
+ } catch (err) {
118
+ caught = err as SymbolsUnavailableError;
119
+ }
120
+ expect(caught?.code).toBe("grammar-incompatible");
121
+ expect(caught?.message).toContain("incompatible");
122
+
123
+ // Same missing-grammar case as the previous test, compared directly here so
124
+ // the distinction is asserted in one place: two different real-world
125
+ // conditions must never collapse into the same code/message/remedy.
126
+ const missing: GrammarDiagnosis[] = [
127
+ { language: "typescript", status: "missing", reason: 'grammar asset "tree-sitter-typescript" is not resolved' },
128
+ ];
129
+ let missingCaught: SymbolsUnavailableError | undefined;
130
+ try {
131
+ requireSymbols({}, missing);
132
+ } catch (err) {
133
+ missingCaught = err as SymbolsUnavailableError;
134
+ }
135
+ expect(missingCaught?.code).not.toBe(caught?.code);
136
+ expect(missingCaught?.message).not.toBe(caught?.message);
137
+ expect(missingCaught?.remedy).not.toBe(caught?.remedy);
138
+ });