@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.
@@ -2,6 +2,17 @@
2
2
  // `metaproject.json` — the opt-in switch for the symbol layer. Pure manifest
3
3
  // transforms (the command layer owns file IO), merge-safe: preserves any other
4
4
  // gdgraph capabilities and never rewrites unrelated modules.
5
+ //
6
+ // Also hosts `requireSymbols()` (AFC-13 / AC5, requirements 2 & 3): the typed,
7
+ // actionable error a caller gets when it explicitly demands symbol-level
8
+ // capability and the graph has no symbol layer — instead of `undefined`, an
9
+ // empty result, or a generic `Error` with a string. When given the tree-sitter
10
+ // adapter's per-language `GrammarDiagnosis[]` (see `./treesitter/adapter`),
11
+ // the error additionally distinguishes "grammar not installed" from "grammar
12
+ // installed but incompatible with this runtime" — distinct causes with
13
+ // distinct remedies, rather than one indistinguishable "unavailable".
14
+
15
+ import type { GrammarDiagnosis } from "./treesitter/adapter";
5
16
 
6
17
  export const TREESITTER_CAPABILITY = "gdgraph.treesitter";
7
18
 
@@ -47,3 +58,97 @@ export function isTreesitterEnabled(manifest: Json): boolean {
47
58
  (c) => c && typeof c === "object" && (c as Json).id === TREESITTER_CAPABILITY && (c as Json).enabled === true,
48
59
  );
49
60
  }
61
+
62
+ // --- requireSymbols (AFC-13 / AC5, requirements 2 & 3) ---
63
+
64
+ export type SymbolsUnavailableCode =
65
+ // The graph has no symbol layer and no diagnoses were supplied to explain
66
+ // why (capability disabled, or the caller didn't pass the adapter's probe).
67
+ | "no-symbol-layer"
68
+ // At least one required language's grammar never resolved: not installed,
69
+ // or failed the Asset Resolver's checksum.
70
+ | "grammar-missing"
71
+ // At least one required language's grammar resolved and verified on disk,
72
+ // but the installed `web-tree-sitter` runtime refused to load it (ABI /
73
+ // version mismatch) — distinct from "missing": nothing to install, the
74
+ // existing install doesn't match the runtime.
75
+ | "grammar-incompatible";
76
+
77
+ // Thrown by `requireSymbols()`. A typed, actionable error — never a generic
78
+ // `Error` with a string, never swallowed into `undefined`/an empty result —
79
+ // naming what capability is missing and what would fix it.
80
+ export class SymbolsUnavailableError extends Error {
81
+ readonly code: SymbolsUnavailableCode;
82
+ readonly remedy: string;
83
+ readonly diagnoses: GrammarDiagnosis[];
84
+
85
+ constructor(code: SymbolsUnavailableCode, message: string, remedy: string, diagnoses: GrammarDiagnosis[] = []) {
86
+ super(message);
87
+ this.name = "SymbolsUnavailableError";
88
+ this.code = code;
89
+ this.remedy = remedy;
90
+ this.diagnoses = diagnoses;
91
+ }
92
+ }
93
+
94
+ // The minimal shape `requireSymbols` needs from a graph. Defined locally
95
+ // rather than imported from `./types` (owned by a concurrent task on this
96
+ // flow) so this module depends on nothing but the one field it actually
97
+ // reads; `GraphData` (which has `symbols?: SymbolNode[]`) satisfies this
98
+ // structurally, so callers can pass a real `GraphData` unchanged.
99
+ export interface SymbolsLayerCarrier {
100
+ symbols?: unknown[];
101
+ }
102
+
103
+ // Explicit demand for symbol-level capability: returns `graph.symbols` when
104
+ // present, otherwise THROWS `SymbolsUnavailableError` instead of returning
105
+ // `undefined` or an empty array. This is the "requireSymbols" contract in
106
+ // AC-13 — a caller that opts into requiring symbols gets a typed, actionable
107
+ // failure, never a silent downgrade to file-level.
108
+ //
109
+ // `diagnoses` (optional) is normally `TreesitterAdapter#getDiagnoses()`
110
+ // (`./treesitter/adapter`) forwarded by the caller; when supplied, the thrown
111
+ // error's `code` distinguishes "grammar-missing" from "grammar-incompatible"
112
+ // (requirement 3) instead of a single generic "unavailable".
113
+ export function requireSymbols<G extends SymbolsLayerCarrier>(
114
+ graph: G,
115
+ diagnoses: GrammarDiagnosis[] = [],
116
+ ): NonNullable<G["symbols"]> {
117
+ if (graph.symbols !== undefined) {
118
+ return graph.symbols as NonNullable<G["symbols"]>;
119
+ }
120
+ throw buildSymbolsUnavailableError(diagnoses);
121
+ }
122
+
123
+ function buildSymbolsUnavailableError(diagnoses: GrammarDiagnosis[]): SymbolsUnavailableError {
124
+ const incompatible = diagnoses.filter((d) => d.status === "incompatible");
125
+ if (incompatible.length > 0) {
126
+ const languages = incompatible.map((d) => d.language).join(", ");
127
+ return new SymbolsUnavailableError(
128
+ "grammar-incompatible",
129
+ `requireSymbols: the installed grammar for [${languages}] is incompatible with this web-tree-sitter runtime (${incompatible
130
+ .map((d) => d.reason)
131
+ .join("; ")})`,
132
+ `Reinstall a "tree-sitter-<language>" grammar build that matches the installed web-tree-sitter runtime version (run "keryx gdgraph assets pull tree-sitter-<language>" after updating .metaproject/assets.lock.json), then rebuild the graph with "keryx gdgraph build".`,
133
+ diagnoses,
134
+ );
135
+ }
136
+
137
+ const missing = diagnoses.filter((d) => d.status === "missing");
138
+ if (missing.length > 0) {
139
+ const languages = missing.map((d) => d.language).join(", ");
140
+ return new SymbolsUnavailableError(
141
+ "grammar-missing",
142
+ `requireSymbols: no grammar is installed for [${languages}]`,
143
+ `Enable the "gdgraph.treesitter" capability in metaproject.json, install the grammar with "keryx gdgraph assets pull tree-sitter-<language>" for each of [${languages}], then rebuild the graph with "keryx gdgraph build".`,
144
+ diagnoses,
145
+ );
146
+ }
147
+
148
+ return new SymbolsUnavailableError(
149
+ "no-symbol-layer",
150
+ "requireSymbols: the graph has no symbol layer (gdgraph.treesitter is disabled, or the build ran without it)",
151
+ 'Enable the "gdgraph.treesitter" capability in metaproject.json and rebuild the graph with "keryx gdgraph build".',
152
+ diagnoses,
153
+ );
154
+ }
@@ -165,6 +165,110 @@ test("availability-false — missing dep ⇒ isAvailable false", async () => {
165
165
  }
166
166
  });
167
167
 
168
+ // --- AFC-13 / AC5 requirement 3: "the grammar is not installed" and "the
169
+ // grammar is installed but its ABI version does not match this runtime" must
170
+ // be distinguishable outcomes, not one collapsed "unavailable". Neither
171
+ // failure mode can be produced on a real machine without either uninstalling
172
+ // `web-tree-sitter` (not available as a seam) or installing an ABI-mismatched
173
+ // grammar build (forbidden by this task). Both are exercised instead through
174
+ // the module's own seam: `spec.load({ dep, asset })` accepts an injected
175
+ // `dep` exactly like the existing tests above, and a `Language.load` that
176
+ // rejects is a faithful simulation of a real ABI/version-mismatch error from
177
+ // `web-tree-sitter` (the real runtime rejects `Language.load` the same way
178
+ // for a grammar built against an incompatible ABI).
179
+ function brokenLanguageParserModule(rejection: Error): unknown {
180
+ function MockParser(this: unknown) {}
181
+ (MockParser as unknown as { init: () => Promise<void> }).init = async () => {};
182
+ (MockParser as unknown as { Language: { load: (p: string) => Promise<unknown> } }).Language = {
183
+ load: async () => {
184
+ throw rejection;
185
+ },
186
+ };
187
+ MockParser.prototype.setLanguage = function setLanguage(): void {};
188
+ MockParser.prototype.parse = function parse(): { rootNode: TsNode } {
189
+ return { rootNode: bootTree() };
190
+ };
191
+ return MockParser;
192
+ }
193
+
194
+ test("AC5.req3 — grammar asset never resolved ⇒ diagnosis status 'missing', not 'incompatible'", async () => {
195
+ const root = await mkdtemp(path.join(tmpdir(), "keryx-ts-diag-missing-"));
196
+ try {
197
+ await mkdir(path.join(root, ".metaproject"), { recursive: true });
198
+ // No assets.lock.json entry at all ⇒ resolveGrammar can never find the asset.
199
+ const spec = createTreesitterSpec(root, { languages: ["typescript"], grammarsPath: null });
200
+ const adapter = spec.load({ dep: mockParserModule(), asset: null }) as unknown as {
201
+ isAvailable(): Promise<boolean>;
202
+ getDiagnoses(): { language: string; status: string; reason: string }[];
203
+ };
204
+
205
+ expect(await adapter.isAvailable()).toBe(false);
206
+ const diagnoses = adapter.getDiagnoses();
207
+ expect(diagnoses).toHaveLength(1);
208
+ expect(diagnoses[0]?.status).toBe("missing");
209
+ expect(diagnoses[0]?.reason).not.toContain("ABI");
210
+ expect(diagnoses[0]?.reason).toContain("not resolved");
211
+ } finally {
212
+ await rm(root, { recursive: true, force: true });
213
+ }
214
+ });
215
+
216
+ test("AC5.req3 — grammar asset resolves and verifies, but the runtime refuses to load it ⇒ diagnosis status 'incompatible', not 'missing'", async () => {
217
+ const { root, grammarsDir } = await makeWorkspaceWithGrammar();
218
+ try {
219
+ const spec = createTreesitterSpec(root, { languages: ["typescript"], grammarsPath: grammarsDir });
220
+ const abiError = new Error("Incompatible language version 13. Expected minimum 14, maximum 15");
221
+ const adapter = spec.load({ dep: brokenLanguageParserModule(abiError), asset: null }) as unknown as {
222
+ isAvailable(): Promise<boolean>;
223
+ getDiagnoses(): { language: string; status: string; reason: string }[];
224
+ };
225
+
226
+ // The grammar resolved (it is on disk with a verified checksum) — this is
227
+ // the crucial difference from the "missing" case above — but the runtime
228
+ // could not load it, so overall availability is still false.
229
+ expect(await adapter.isAvailable()).toBe(false);
230
+ const diagnoses = adapter.getDiagnoses();
231
+ expect(diagnoses).toHaveLength(1);
232
+ expect(diagnoses[0]?.status).toBe("incompatible");
233
+ expect(diagnoses[0]?.reason).toContain("Incompatible language version 13");
234
+ expect(diagnoses[0]?.reason).not.toContain("not resolved");
235
+ } finally {
236
+ await rm(root, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ test("AC5.req3 — missing and incompatible are genuinely different values end to end (adapter diagnosis -> warn reason -> requireSymbols code), never the same outcome", async () => {
241
+ const missingRoot = await mkdtemp(path.join(tmpdir(), "keryx-ts-diag-a-"));
242
+ const incompatibleWorkspace = await makeWorkspaceWithGrammar();
243
+ try {
244
+ await mkdir(path.join(missingRoot, ".metaproject"), { recursive: true });
245
+ const missingSpec = createTreesitterSpec(missingRoot, { languages: ["typescript"], grammarsPath: null });
246
+ const missingAdapter = missingSpec.load({ dep: mockParserModule(), asset: null }) as unknown as {
247
+ isAvailable(): Promise<boolean>;
248
+ getDiagnoses(): { language: string; status: string; reason: string }[];
249
+ };
250
+ await missingAdapter.isAvailable();
251
+
252
+ const incompatibleSpec = createTreesitterSpec(incompatibleWorkspace.root, {
253
+ languages: ["typescript"],
254
+ grammarsPath: incompatibleWorkspace.grammarsDir,
255
+ });
256
+ const incompatibleAdapter = incompatibleSpec.load({
257
+ dep: brokenLanguageParserModule(new Error("bad wasm magic number")),
258
+ asset: null,
259
+ }) as unknown as {
260
+ isAvailable(): Promise<boolean>;
261
+ getDiagnoses(): { language: string; status: string; reason: string }[];
262
+ };
263
+ await incompatibleAdapter.isAvailable();
264
+
265
+ expect(missingAdapter.getDiagnoses()[0]?.status).not.toBe(incompatibleAdapter.getDiagnoses()[0]?.status);
266
+ } finally {
267
+ await rm(missingRoot, { recursive: true, force: true });
268
+ await rm(incompatibleWorkspace.root, { recursive: true, force: true });
269
+ }
270
+ });
271
+
168
272
  test("AC1.1 additive write path — enrich writes symbols.jsonl + calls.jsonl via a mock adapter", async () => {
169
273
  const root = await mkdtemp(path.join(tmpdir(), "keryx-enrich-"));
170
274
  try {
@@ -37,6 +37,46 @@ export interface TreesitterAdapterConfig {
37
37
  grammarsPath: string | null;
38
38
  }
39
39
 
40
+ // Per-language probe outcome (AFC-13 / AC5). "missing" and "incompatible" are
41
+ // deliberately distinct: a grammar that never resolved (not installed, or
42
+ // failed the Asset Resolver's checksum) has a different remedy — install it —
43
+ // from one that resolved and verified on disk but the installed
44
+ // `web-tree-sitter` runtime refuses to load it (an ABI/version mismatch
45
+ // between the pinned grammar build and the runtime) — whose remedy is
46
+ // reinstalling a matching build, not installing anything new. Collapsing
47
+ // these into one "unavailable" outcome is the defect class AC5 targets.
48
+ export type GrammarDiagnosisStatus = "ok" | "missing" | "incompatible";
49
+
50
+ export interface GrammarDiagnosis {
51
+ language: GrammarLanguage;
52
+ status: GrammarDiagnosisStatus;
53
+ // Human-actionable detail; empty for "ok".
54
+ reason: string;
55
+ }
56
+
57
+ // Render diagnoses into one warn-once-friendly summary line, grouped by
58
+ // status so a mixed missing+incompatible result stays legible.
59
+ export function describeGrammarDiagnoses(diagnoses: GrammarDiagnosis[]): string {
60
+ if (diagnoses.length === 0) {
61
+ return "no grammar languages configured";
62
+ }
63
+ const byStatus = (status: GrammarDiagnosisStatus) =>
64
+ diagnoses.filter((d) => d.status === status).map((d) => d.language);
65
+ const missing = byStatus("missing");
66
+ const incompatible = byStatus("incompatible");
67
+ const parts: string[] = [];
68
+ if (missing.length > 0) {
69
+ parts.push(`missing grammar for [${missing.join(", ")}]`);
70
+ }
71
+ if (incompatible.length > 0) {
72
+ parts.push(`incompatible grammar for [${incompatible.join(", ")}]`);
73
+ }
74
+ if (parts.length === 0) {
75
+ return "no configured grammar resolved to a usable parser";
76
+ }
77
+ return parts.join("; ");
78
+ }
79
+
40
80
  // Minimal shapes of the `web-tree-sitter` surface we touch (kept local so the
41
81
  // dep is never imported for types either — structural typing only).
42
82
  interface ParserLike {
@@ -138,7 +178,15 @@ export async function resolveTreesitterCapability(
138
178
  return null;
139
179
  }
140
180
  if (!available) {
141
- warnCapabilityDegraded(spec.id, "adapter reported unavailable");
181
+ // Prefer the per-language diagnoses when the concrete adapter exposes
182
+ // them (only `TreesitterAdapter` does — this cast is local and narrow,
183
+ // never leaks into the shared `CapabilityAdapter` interface), so the
184
+ // one-line warn-once already names missing vs incompatible instead of
185
+ // a generic "unavailable".
186
+ const diagnoses = getGrammarDiagnoses(adapter as unknown as { getDiagnoses?: () => GrammarDiagnosis[] });
187
+ const reason =
188
+ diagnoses.length > 0 ? describeGrammarDiagnoses(diagnoses) : "adapter reported unavailable";
189
+ warnCapabilityDegraded(spec.id, reason);
142
190
  return null;
143
191
  }
144
192
 
@@ -149,9 +197,23 @@ export async function resolveTreesitterCapability(
149
197
  }
150
198
  }
151
199
 
200
+ // Read `getDiagnoses()` off a `CapabilityAdapter` when the concrete instance
201
+ // is a `TreesitterAdapter` (the only implementation today). A generic
202
+ // `CapabilityAdapter` from another module simply has no such method, so this
203
+ // is a safe narrow probe rather than an assumption about the seam's shared
204
+ // interface.
205
+ function getGrammarDiagnoses(adapter: { getDiagnoses?: () => GrammarDiagnosis[] }): GrammarDiagnosis[] {
206
+ return typeof adapter.getDiagnoses === "function" ? adapter.getDiagnoses() : [];
207
+ }
208
+
152
209
  class TreesitterAdapter implements CapabilityAdapter<BuildInput, SymbolLayer> {
153
210
  readonly id = "gdgraph.treesitter";
154
211
  private grammars: ResolvedGrammar[] = [];
212
+ private diagnoses: GrammarDiagnosis[] = [];
213
+ // Populated by `isAvailable()`'s probe so `run()` never re-attempts a
214
+ // `loadLanguage()` call the probe already made (and, for a language that
215
+ // probed "incompatible", never attempts it at all).
216
+ private loadedLanguages = new Map<GrammarLanguage, unknown>();
155
217
 
156
218
  constructor(
157
219
  private readonly cwd: string,
@@ -159,13 +221,88 @@ class TreesitterAdapter implements CapabilityAdapter<BuildInput, SymbolLayer> {
159
221
  private readonly dep: unknown,
160
222
  ) {}
161
223
 
224
+ // Exposes the per-language missing-vs-incompatible breakdown from the most
225
+ // recent `isAvailable()` probe (AFC-13 / AC5, requirement 3). Empty before
226
+ // `isAvailable()` has run.
227
+ getDiagnoses(): GrammarDiagnosis[] {
228
+ return this.diagnoses;
229
+ }
230
+
162
231
  async isAvailable(): Promise<boolean> {
232
+ const languages = toGrammarLanguages(this.config.languages);
233
+ this.diagnoses = [];
234
+ this.loadedLanguages = new Map();
235
+ this.grammars = [];
236
+
163
237
  if (!this.dep) {
238
+ this.diagnoses = languages.map((language) => ({
239
+ language,
240
+ status: "missing",
241
+ reason: 'optional dependency "web-tree-sitter" is not installed',
242
+ }));
164
243
  return false;
165
244
  }
166
- const languages = toGrammarLanguages(this.config.languages);
167
- this.grammars = await resolveGrammars(this.cwd, languages, this.config.grammarsPath);
168
- return this.grammars.length > 0;
245
+
246
+ const resolved = await resolveGrammars(this.cwd, languages, this.config.grammarsPath);
247
+ const resolvedByLanguage = new Map(resolved.map((grammar) => [grammar.language, grammar]));
248
+
249
+ const api = normalizeParserApi(this.dep);
250
+ if (typeof api?.init === "function") {
251
+ try {
252
+ await api.init();
253
+ } catch {
254
+ // Runtime init failure ⇒ every resolved grammar is unloadable; each
255
+ // still gets its own diagnosis below (loadLanguage will also throw).
256
+ }
257
+ }
258
+
259
+ for (const language of languages) {
260
+ const grammar = resolvedByLanguage.get(language);
261
+ if (!grammar) {
262
+ this.diagnoses.push({
263
+ language,
264
+ status: "missing",
265
+ reason: `grammar asset "tree-sitter-${language}" is not resolved (not installed, or failed checksum verification)`,
266
+ });
267
+ continue;
268
+ }
269
+ if (!api) {
270
+ this.diagnoses.push({
271
+ language,
272
+ status: "incompatible",
273
+ reason: 'the "web-tree-sitter" module shape was not recognized (no usable Parser export)',
274
+ });
275
+ continue;
276
+ }
277
+ try {
278
+ const loaded = await api.loadLanguage(grammar.path);
279
+ if (!loaded) {
280
+ this.diagnoses.push({
281
+ language,
282
+ status: "incompatible",
283
+ reason: `grammar at "${grammar.path}" loaded but produced no Language object`,
284
+ });
285
+ continue;
286
+ }
287
+ this.loadedLanguages.set(language, loaded);
288
+ this.grammars.push(grammar);
289
+ this.diagnoses.push({ language, status: "ok", reason: "" });
290
+ } catch (error) {
291
+ // The grammar resolved and verified on disk, but the installed
292
+ // `web-tree-sitter` runtime refused to load it — an ABI/version
293
+ // mismatch between the pinned grammar build and the runtime, NOT a
294
+ // missing asset. Distinct cause, distinct remedy (AFC-13).
295
+ this.diagnoses.push({
296
+ language,
297
+ status: "incompatible",
298
+ reason: `grammar at "${grammar.path}" failed to load in the installed web-tree-sitter runtime (likely an ABI/version mismatch): ${
299
+ error instanceof Error ? error.message : String(error)
300
+ }`,
301
+ });
302
+ }
303
+ }
304
+
305
+ return this.diagnoses.some((d) => d.status === "ok");
169
306
  }
170
307
 
171
308
  async run(input: BuildInput): Promise<SymbolLayer> {
@@ -178,11 +315,14 @@ class TreesitterAdapter implements CapabilityAdapter<BuildInput, SymbolLayer> {
178
315
  await api.init();
179
316
  }
180
317
 
181
- // Load + cache one parser per resolved grammar language.
318
+ // Load + cache one parser per resolved grammar language. Reuse the
319
+ // Language object the `isAvailable()` probe already loaded when present
320
+ // (the common path); fall back to loading directly for a caller that
321
+ // invokes `run()` without having called `isAvailable()` first.
182
322
  const parsers = new Map<GrammarLanguage, ParserLike>();
183
323
  for (const grammar of this.grammars) {
184
324
  try {
185
- const language = await api.loadLanguage(grammar.path);
325
+ const language = this.loadedLanguages.get(grammar.language) ?? (await api.loadLanguage(grammar.path));
186
326
  if (!language) {
187
327
  continue;
188
328
  }
@@ -0,0 +1,71 @@
1
+ // AFC-13 / AC5 requirement 4: "fixture даёт реальные symbols после явной
2
+ // установки" — a fixture that genuinely yields real symbols after an
3
+ // explicit runtime/grammar installation. This task is forbidden from
4
+ // performing that installation (no dependency installs of any kind).
5
+ //
6
+ // So this file does NOT install anything. It probes whether a working
7
+ // `web-tree-sitter` runtime + a real, verified grammar are ALREADY present in
8
+ // this environment (exactly the state an explicit install would produce) and:
9
+ // - genuinely present ⇒ the test RUNS FOR REAL against the real adapter
10
+ // (`resolveTreesitterCapability`, the actual production entry point —
11
+ // not a mock) and asserts real, non-empty symbols extracted from a real
12
+ // TypeScript fixture by the real grammar.
13
+ // - genuinely absent ⇒ `test.skipIf` marks the test SKIPPED (visible as
14
+ // "skip" in `bun test`'s own summary, not a silent pass), with the reason
15
+ // embedded in the test name.
16
+ //
17
+ // A vacuous pass (the "absent" branch quietly returning without asserting
18
+ // anything) would NOT be evidence for requirement 4 — `test.skipIf` is used
19
+ // specifically so an absent environment shows up as a skip, never as a pass.
20
+
21
+ import { expect, test } from "bun:test";
22
+ import { resolveTreesitterCapability } from "./adapter";
23
+
24
+ const FIXTURE_SOURCE = ["export function boot(): void {", " tick();", "}", "", "function tick(): void {}", ""].join(
25
+ "\n",
26
+ );
27
+
28
+ // Probed once at module load (top-level await — this file's only test needs
29
+ // the answer before `test.skipIf` decides whether to run). This calls the
30
+ // REAL `resolveTreesitterCapability` against the real project root, so it
31
+ // reflects whatever runtime + grammar state is genuinely on this machine —
32
+ // no mocking, no injected `dep`.
33
+ const projectRoot = process.cwd();
34
+ const probedAdapter = await resolveTreesitterCapability(projectRoot, {
35
+ languages: ["typescript"],
36
+ grammarsPath: null,
37
+ });
38
+ const skipReason = probedAdapter
39
+ ? ""
40
+ : "no working web-tree-sitter runtime + verified typescript grammar resolved in this environment " +
41
+ "(gdgraph.treesitter must be enabled in metaproject.json AND a matching grammar installed via " +
42
+ '"keryx gdgraph assets pull tree-sitter-typescript" — an install this task must not perform)';
43
+
44
+ test.skipIf(!probedAdapter)(
45
+ skipReason ? `AC5.req4 — real fixture yields real symbols [SKIPPED: ${skipReason}]` : "AC5.req4 — real fixture yields real symbols after explicit installation",
46
+ async () => {
47
+ const adapter = probedAdapter;
48
+ if (!adapter) {
49
+ // Unreachable when the test actually runs (skipIf gates it), kept only
50
+ // so TypeScript sees the non-null adapter below without a cast.
51
+ throw new Error("AC5.req4 ran without a resolved adapter — skipIf condition was wrong");
52
+ }
53
+
54
+ const layer = await adapter.run({
55
+ files: [{ path: "fixtures/treesitter/ac5-req4-fixture.ts", content: FIXTURE_SOURCE }],
56
+ });
57
+
58
+ // Real symbols, not a mock's canned output: names, kinds, and a resolved
59
+ // call edge all come from the actually-installed tree-sitter grammar
60
+ // parsing actual TypeScript source.
61
+ expect(layer.symbols.length).toBeGreaterThan(0);
62
+ const byName = Object.fromEntries(layer.symbols.map((s) => [s.name, s]));
63
+ expect(byName.boot).toMatchObject({ kind: "function", language: "typescript" });
64
+ expect(byName.tick).toMatchObject({ kind: "function", language: "typescript" });
65
+
66
+ const callSummaries = layer.calls.map((c) => `${c.kind}:${c.from}=>${c.to}`);
67
+ expect(callSummaries).toContain(
68
+ "calls:fixtures/treesitter/ac5-req4-fixture.ts#boot=>fixtures/treesitter/ac5-req4-fixture.ts#tick",
69
+ );
70
+ },
71
+ );
@@ -27,10 +27,35 @@ export type TranspilerImportKind =
27
27
  // plain regex with no notion of "static" vs "dynamic". Never infer one;
28
28
  // `UNKNOWN_IMPORT_KIND` marks it explicitly and cycle detection treats it as
29
29
  // load-order (the pre-fix behavior), so fallback-only edges are never
30
- // silently excluded from a real cycle.
30
+ // silently excluded from a real cycle. This is reserved for the case where
31
+ // `scanImports` could not even run (unparseable source) or the language has
32
+ // no transpiler support at all (Java/Python) — genuinely unknown provenance,
33
+ // where "assume load-order" is the safe default.
31
34
  export const UNKNOWN_IMPORT_KIND = "unknown-static" as const;
32
35
 
33
- export type ImportKind = TranspilerImportKind | typeof UNKNOWN_IMPORT_KIND;
36
+ // AFC-11 (flow 234): a specifier the transpiler DID successfully scan the
37
+ // file for, but did not report for THIS statement, because the statement
38
+ // carries no runtime binding — `import type {...}`, `export type {...} from`,
39
+ // or an inline specifier list where every name is `type`-prefixed
40
+ // (`import { type X } from "./m"`). `Bun.Transpiler#scanImports` erases these
41
+ // (verified empirically: this repo's `tsconfig.json` sets neither
42
+ // `importsNotUsedAsValues` nor `verbatimModuleSyntax`, so it runs on
43
+ // TypeScript's defaults, which elide type-only imports/exports at compile
44
+ // time), so they only reach the graph through the regex fallback below.
45
+ // Unlike `UNKNOWN_IMPORT_KIND`, the provenance here is NOT unknown — it is
46
+ // known to be type-only — so it gets its own kind rather than being folded
47
+ // into "unknown-static": `getCycles` (query.ts) excludes it from the
48
+ // load-order adjacency (a type-only cycle cannot deadlock a runtime module
49
+ // graph — it does not exist once compiled away), while every consumer that
50
+ // reads `edge.kind` (getOrphans, getAffected, computeAffected) is untouched
51
+ // and keeps treating it as a real dependency edge for impact analysis, since
52
+ // the edge is genuinely real for "what has to be re-checked".
53
+ export const TYPE_ONLY_IMPORT_KIND = "type-only" as const;
54
+
55
+ export type ImportKind =
56
+ | TranspilerImportKind
57
+ | typeof UNKNOWN_IMPORT_KIND
58
+ | typeof TYPE_ONLY_IMPORT_KIND;
34
59
 
35
60
  export type GraphEdge = {
36
61
  id: string;
@@ -10,7 +10,7 @@ triggers:
10
10
  - "Implement issue task"
11
11
  metadata:
12
12
  author: "MrCipherSmith"
13
- version: "1.3.0"
13
+ version: "1.3.1"
14
14
  category: "implementation"
15
15
  agent_worthy: true
16
16
  compatible_harnesses: "claude,cursor,codex,zed,opencode"
@@ -23,7 +23,7 @@ license: "MIT"
23
23
 
24
24
  Receives a single atomic task (JSON task object from `issue-analyzer`) and implements it end-to-end. Designed to run autonomously as a sub-agent — no user interaction required. Commits its changes to a shared feature branch managed by the orchestrator.
25
25
 
26
- **Input:** JSON task object + workspace context (branch, codebase path, issue number)
26
+ **Input:** JSON task object + workspace context (branch, codebase path, optional real issue number)
27
27
  **Output:** JSON result object with implementation status, files modified, verification results
28
28
 
29
29
  ## When to Use
@@ -86,10 +86,12 @@ TASK: (from JSON object passed by orchestrator)
86
86
  WORKSPACE:
87
87
  codebase_path: absolute path to the repository
88
88
  branch: feature branch to work on (already checked out by orchestrator)
89
- issue_number: GitHub issue number (for commit messages)
89
+ issue_number: Optional real GitHub issue number; omit for description-based tasks
90
90
  issue_title: issue title (for commit messages)
91
91
  ```
92
92
 
93
+ For a description-based task, omit `issue_number` and issue references in commit messages. Never substitute a flow ID or fabricate a GitHub issue. A supplied issue number must remain a positive integer.
94
+
93
95
  **1.3 For fix tasks (dispatched from review loop):**
94
96
 
95
97
  ```
@@ -120,7 +122,7 @@ The refusals are the contract's, not a checklist you run by eye
120
122
  | `task_type` outside `ui_component\|store_logic\|service_api\|refactoring\|fix\|mixed` | `task.task_type.enum` |
121
123
  | A `task_id` that is not `task-<n>` | `task.task_id.pattern` |
122
124
  | Empty `target_files` or empty `acceptance_criteria` | `minItems: 1` on both |
123
- | Missing `codebase_path` / `branch` / `issue_number` | `workspace.required` |
125
+ | Missing `codebase_path` / `branch` | `workspace.required` |
124
126
  | `skip_confirmation` anything but `true` | `automation.skip_confirmation.const` |
125
127
  | `max_self_fix_attempts` above 3 | `automation.max_self_fix_attempts.maximum` |
126
128
  | Any field the contract does not declare | `additionalProperties: false` |
@@ -315,7 +317,7 @@ Execute the change plan. Write production-quality code.
315
317
 
316
318
  **4.5 Commit after implementation:**
317
319
 
318
- Create a conventional commit with the changes:
320
+ When auto-commit is enabled, create a conventional commit with the changes. Include the `refs #<issue_number>` line below only when a positive real issue number was supplied; otherwise omit that entire line:
319
321
  ```bash
320
322
  git add <modified files>
321
323
  git commit -m "<type>(<scope>): <description>
@@ -399,6 +401,7 @@ said. Report the block instead, naming what repeated.
399
401
  **Never run `git reset --hard`, `git clean`, or any unscoped revert.** You do not own the worktree. `job-orchestrator` dispatches implementers in PARALLEL WAVES sharing a single worktree, so an unscoped reset destroys a wave-mate's uncommitted work — work that is not yours, cannot be recovered, and whose loss is invisible to you because the other agent's failure surfaces somewhere else entirely. If you cannot identify which files are yours, leave the tree exactly as it is and say so in the report: a dirty tree is recoverable, a destroyed one is not.
400
402
 
401
403
  **5.5 Re-commit fixes if any:**
404
+ When auto-commit is enabled, use the template below. Include `refs #<issue_number>` only for a supplied positive real issue number; otherwise omit that entire line.
402
405
  ```bash
403
406
  git add <fixed files>
404
407
  git commit -m "fix(<scope>): resolve lint/type/test issues
@@ -530,7 +533,7 @@ second copy of a schema is how that happens.
530
533
  5. **DO** use project path aliases (`@components`, `@utils`, etc.) for imports.
531
534
  6. **DO** wrap React components with `observer()` when they access MobX stores.
532
535
  7. **DO** use `runInAction()` after every `await` in MobX actions.
533
- 8. **DO** commit with conventional commit format referencing the issue number.
536
+ 8. **DO** use conventional commit format when auto-commit is enabled. Reference only a supplied real issue number; omit the issue reference when absent.
534
537
  9. **DO** verify your work before reporting.
535
538
  10. **DO** make `STATUS: <TOKEN>` the first line of your final message, and put no
536
539
  JSON in the response body. The full JSON result is the file Phase 6.1 writes
@@ -10,7 +10,7 @@ triggers:
10
10
  - "Implement issue task"
11
11
  metadata:
12
12
  author: "MrCipherSmith"
13
- version: "1.3.0"
13
+ version: "1.3.1"
14
14
  category: "implementation"
15
15
  agent_worthy: true
16
16
  compatible_harnesses: "claude,cursor,codex,zed,opencode"
@@ -23,7 +23,7 @@ license: "MIT"
23
23
 
24
24
  Receives a single atomic task (JSON task object from `issue-analyzer`) and implements it end-to-end. Designed to run autonomously as a sub-agent — no user interaction required. Commits its changes to a shared feature branch managed by the orchestrator.
25
25
 
26
- **Input:** JSON task object + workspace context (branch, codebase path, issue number)
26
+ **Input:** JSON task object + workspace context (branch, codebase path, optional real issue number)
27
27
  **Output:** JSON result object with implementation status, files modified, verification results
28
28
 
29
29
  ## When to Use
@@ -86,10 +86,12 @@ TASK: (from JSON object passed by orchestrator)
86
86
  WORKSPACE:
87
87
  codebase_path: absolute path to the repository
88
88
  branch: feature branch to work on (already checked out by orchestrator)
89
- issue_number: GitHub issue number (for commit messages)
89
+ issue_number: Optional real GitHub issue number; omit for description-based tasks
90
90
  issue_title: issue title (for commit messages)
91
91
  ```
92
92
 
93
+ For a description-based task, omit `issue_number` and issue references in commit messages. Never substitute a flow ID or fabricate a GitHub issue. A supplied issue number must remain a positive integer.
94
+
93
95
  **1.3 For fix tasks (dispatched from review loop):**
94
96
 
95
97
  ```
@@ -120,7 +122,7 @@ The refusals are the contract's, not a checklist you run by eye
120
122
  | `task_type` outside `ui_component\|store_logic\|service_api\|refactoring\|fix\|mixed` | `task.task_type.enum` |
121
123
  | A `task_id` that is not `task-<n>` | `task.task_id.pattern` |
122
124
  | Empty `target_files` or empty `acceptance_criteria` | `minItems: 1` on both |
123
- | Missing `codebase_path` / `branch` / `issue_number` | `workspace.required` |
125
+ | Missing `codebase_path` / `branch` | `workspace.required` |
124
126
  | `skip_confirmation` anything but `true` | `automation.skip_confirmation.const` |
125
127
  | `max_self_fix_attempts` above 3 | `automation.max_self_fix_attempts.maximum` |
126
128
  | Any field the contract does not declare | `additionalProperties: false` |
@@ -315,7 +317,7 @@ Execute the change plan. Write production-quality code.
315
317
 
316
318
  **4.5 Commit after implementation:**
317
319
 
318
- Create a conventional commit with the changes:
320
+ When auto-commit is enabled, create a conventional commit with the changes. Include the `refs #<issue_number>` line below only when a positive real issue number was supplied; otherwise omit that entire line:
319
321
  ```bash
320
322
  git add <modified files>
321
323
  git commit -m "<type>(<scope>): <description>
@@ -399,6 +401,7 @@ said. Report the block instead, naming what repeated.
399
401
  **Never run `git reset --hard`, `git clean`, or any unscoped revert.** You do not own the worktree. `job-orchestrator` dispatches implementers in PARALLEL WAVES sharing a single worktree, so an unscoped reset destroys a wave-mate's uncommitted work — work that is not yours, cannot be recovered, and whose loss is invisible to you because the other agent's failure surfaces somewhere else entirely. If you cannot identify which files are yours, leave the tree exactly as it is and say so in the report: a dirty tree is recoverable, a destroyed one is not.
400
402
 
401
403
  **5.5 Re-commit fixes if any:**
404
+ When auto-commit is enabled, use the template below. Include `refs #<issue_number>` only for a supplied positive real issue number; otherwise omit that entire line.
402
405
  ```bash
403
406
  git add <fixed files>
404
407
  git commit -m "fix(<scope>): resolve lint/type/test issues
@@ -530,7 +533,7 @@ second copy of a schema is how that happens.
530
533
  5. **DO** use project path aliases (`@components`, `@utils`, etc.) for imports.
531
534
  6. **DO** wrap React components with `observer()` when they access MobX stores.
532
535
  7. **DO** use `runInAction()` after every `await` in MobX actions.
533
- 8. **DO** commit with conventional commit format referencing the issue number.
536
+ 8. **DO** use conventional commit format when auto-commit is enabled. Reference only a supplied real issue number; omit the issue reference when absent.
534
537
  9. **DO** verify your work before reporting.
535
538
  10. **DO** make `STATUS: <TOKEN>` the first line of your final message, and put no
536
539
  JSON in the response body. The full JSON result is the file Phase 6.1 writes