@mrciphersmith/keryx 0.2.80 → 0.2.82
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.
- package/dist/cli.js +8084 -3465
- package/package.json +20 -6
- package/src/gdgraph/affected.test.ts +33 -0
- package/src/gdgraph/build.ts +54 -17
- package/src/gdgraph/fallback.test.ts +1 -1
- package/src/gdgraph/import-kind.test.ts +342 -22
- package/src/gdgraph/query.ts +14 -1
- package/src/gdgraph/service.test.ts +31 -1
- package/src/gdgraph/service.ts +33 -1
- package/src/gdgraph/staleness.test.ts +208 -0
- package/src/gdgraph/staleness.ts +195 -13
- package/src/gdgraph/symbols-capability.test.ts +98 -1
- package/src/gdgraph/symbols-capability.ts +105 -0
- package/src/gdgraph/treesitter/adapter.test.ts +104 -0
- package/src/gdgraph/treesitter/adapter.ts +146 -6
- package/src/gdgraph/treesitter/real-grammar-fixture.test.ts +71 -0
- package/src/gdgraph/types.ts +27 -2
- package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.codex.md +9 -6
- package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.cursor.md +9 -6
- package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.md +9 -6
- package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.opencode.md +9 -6
- package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.zed.md +9 -6
- package/src/gdskills/bundled/skills/orchestration/task-implementer/input-contract.schema.json +2 -2
- package/src/gdskills/bundled/skills/orchestration/task-implementer/orchestrator-prompt.md +2 -2
package/src/gdgraph/query.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/gdgraph/service.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/gdgraph/staleness.ts
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 {
|
|
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
|
+
});
|