@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.80",
3
+ "version": "0.2.82",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -38,11 +38,13 @@
38
38
  "build": "bun build ./src/cli.ts --outdir ./dist --target bun --external @modelcontextprotocol/sdk --external web-tree-sitter --external @opentui/core && bun build ./src/harness/process/sandbox/proxy-worker.ts --outdir ./dist --target bun --external @modelcontextprotocol/sdk --external web-tree-sitter --external @opentui/core",
39
39
  "prepare": "bun run build",
40
40
  "typecheck": "tsc --noEmit",
41
+ "typecheck:scripts": "tsc --project tsconfig.scripts.json --noEmit",
41
42
  "test": "bun test",
42
- "check": "tsc --noEmit && bun test",
43
+ "check": "bun run lint && bun run typecheck && bun run typecheck:scripts && bun test",
43
44
  "check:doc-links": "bun scripts/check-doc-links.ts",
44
45
  "baseline:review-precision": "bun scripts/review-precision-baseline.ts",
45
- "test:guards": "bun test src/lib/config-dir.ast.test.ts src/lib/config-dir.readers.test.ts src/lib/production-graph.test.ts src/harness/policy/profiles.test.ts src/lib/serve-server.test.ts src/gdskills/agent-catalogue-xref.test.ts src/gdskills/enforcement-claims.test.ts"
46
+ "test:guards": "bun test src/lib/config-dir.ast.test.ts src/lib/config-dir.readers.test.ts src/lib/production-graph.test.ts src/harness/policy/profiles.test.ts src/lib/serve-server.test.ts src/gdskills/agent-catalogue-xref.test.ts src/gdskills/enforcement-claims.test.ts",
47
+ "lint": "eslint ."
46
48
  },
47
49
  "files": [
48
50
  "dist",
@@ -55,16 +57,28 @@
55
57
  "package.json"
56
58
  ],
57
59
  "dependencies": {},
60
+ "overrides": {
61
+ "fast-uri": "^3.1.6",
62
+ "ip-address": "^10.3.1",
63
+ "hono": "^4.12.34",
64
+ "qs": "^6.16.0",
65
+ "@hono/node-server": "^1.19.15",
66
+ "protobufjs": "7.6.6",
67
+ "sharp": "0.35.4"
68
+ },
58
69
  "optionalDependencies": {
59
- "@modelcontextprotocol/sdk": "^1.0.0",
70
+ "@modelcontextprotocol/sdk": "^1.30.0",
60
71
  "@opentui/core": "^0.4.5",
61
- "web-tree-sitter": "^0.22.0"
72
+ "web-tree-sitter": "^0.22.6"
62
73
  },
63
74
  "devDependencies": {
75
+ "@eslint/js": "^10.0.1",
64
76
  "@types/bun": "latest",
65
77
  "@xenova/transformers": "^2.17.2",
66
78
  "bun-types": "latest",
67
- "typescript": "^5"
79
+ "eslint": "^10.10.0",
80
+ "typescript": "^5.9.3",
81
+ "typescript-eslint": "^8.69.0"
68
82
  },
69
83
  "engines": {
70
84
  "bun": ">=1.1.0"
@@ -98,3 +98,36 @@ test("AC2.4 — dependencies are the unchanged one-hop forward set", async () =>
98
98
  expect(result.dependencies).toEqual(deps);
99
99
  }
100
100
  });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // AFC-11 (flow 234) requirement 3 — a type consumer is visible in impact
104
+ // analysis. `edge.importKind === "type-only"` is erased at runtime and is
105
+ // excluded from `getCycles`' load-order adjacency, but the edge itself is
106
+ // still `kind: "imports"` — a real dependency for "what do I have to
107
+ // re-check" — so `computeAffected` (and `getAffected`) must keep surfacing
108
+ // it. Constructed in-memory rather than through `buildGraph()`: this is a
109
+ // pure-function contract on `GraphData`, unrelated to how the edge's
110
+ // `importKind` was derived.
111
+ // ---------------------------------------------------------------------------
112
+
113
+ test("AFC-11 req3 — a file that only type-imports the target is still a visible dependent", () => {
114
+ const graph: GraphData = {
115
+ nodes: [
116
+ { id: "src/types.ts", kind: "file", path: "src/types.ts", language: "typescript" },
117
+ { id: "src/consumer.ts", kind: "file", path: "src/consumer.ts", language: "typescript" },
118
+ ],
119
+ edges: [
120
+ {
121
+ id: "edge:1",
122
+ from: "src/consumer.ts",
123
+ to: "src/types.ts",
124
+ kind: "imports",
125
+ specifier: "./types",
126
+ importKind: "type-only",
127
+ },
128
+ ],
129
+ };
130
+
131
+ expect(computeAffected(graph, "src/types.ts").dependents).toEqual(["src/consumer.ts"]);
132
+ expect(getAffected(graph, "src/types.ts").dependents).toEqual(["src/consumer.ts"]);
133
+ });
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import type { GraphData, GraphEdge, GraphNode, ImportKind, TranspilerImportKind } from "./types";
5
- import { UNKNOWN_IMPORT_KIND } from "./types";
5
+ import { TYPE_ONLY_IMPORT_KIND, UNKNOWN_IMPORT_KIND } from "./types";
6
6
 
7
7
  const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".java", ".py"];
8
8
  const SOURCE_RESOLUTION_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".java", ".py"];
@@ -112,7 +112,7 @@ export async function buildGraph(projectRoot: string): Promise<BuildResult> {
112
112
  // dropped), so the resolution metric is honest. TS/JS keeps the exact
113
113
  // original guard (relative + tsconfig alias only) ⇒ byte-identical output.
114
114
  const isLanguageAware = language === "java" || language === "python";
115
- const records = extractImportRecords(content, language);
115
+ const records = extractImportRecords(content, language, file);
116
116
 
117
117
  for (const { specifier, kind: importKind } of records) {
118
118
  const resolved = resolveImport(projectRoot, file, specifier, fileSet, resolver);
@@ -222,7 +222,7 @@ type ImportRecord = { specifier: string; kind: ImportKind };
222
222
  // loop reads `record.kind` straight onto the edge, so cycle detection can
223
223
  // later tell a load-order `import-statement` from a call-time `dynamic-import`
224
224
  // instead of the previous single "imports" bucket.
225
- function extractImportRecords(content: string, language: string): ImportRecord[] {
225
+ function extractImportRecords(content: string, language: string, filePath: string): ImportRecord[] {
226
226
  // Java/Python are not TS/JS syntax — the tsx transpiler cannot scan them
227
227
  // (it throws today, which is why they already reach the fallback). Route them
228
228
  // explicitly to the regex fallback that carries the java/python patterns,
@@ -244,7 +244,8 @@ function extractImportRecords(content: string, language: string): ImportRecord[]
244
244
  // dependents and showed up as a false orphan. UNION the two extractors: the
245
245
  // transpiler contributes the specifiers a regex cannot see (dynamic/`require`,
246
246
  // odd formatting), the fallback contributes the type-only ones.
247
- const scanned = scanImportsOrEmpty(content);
247
+ const scanResult = scanImportsOrEmpty(content, filePath);
248
+ const scanned = scanResult.imports;
248
249
  const fallback = extractImportSpecifiersFallback(content);
249
250
 
250
251
  const kindBySpecifier = new Map<string, ImportKind>();
@@ -259,13 +260,28 @@ function extractImportRecords(content: string, language: string): ImportRecord[]
259
260
  kindBySpecifier.set(specifier, kind);
260
261
  }
261
262
  }
262
- // Fallback-only specifiers (not seen by the transpiler at all — e.g.
263
- // type-only imports) get the explicit unknown/static marker, never a
264
- // guessed kind (AC4). A specifier the transpiler DID see keeps its real
265
- // kind; the fallback never overrides it.
263
+ // Fallback-only specifiers (not seen by the transpiler for THIS statement)
264
+ // split into two provenances (AFC-11, flow 234):
265
+ // * the scan RAN and simply omitted the specifier — that only happens
266
+ // because the statement carries no runtime binding (`import type`,
267
+ // `export type … from`, or every named specifier is `type`-prefixed).
268
+ // That is a KNOWN classification, `TYPE_ONLY_IMPORT_KIND`, never
269
+ // "unknown" — it still becomes a real edge for impact analysis
270
+ // (getOrphans/getAffected/computeAffected all read `edge.kind`, not
271
+ // `importKind`, so they are unaffected), but `getCycles` excludes it
272
+ // from load-order adjacency the same way it already excludes
273
+ // `dynamic-import`.
274
+ // * the scan did not run at all (unparseable source, or a
275
+ // transpiler-unsupported language routed here) — genuinely unknown
276
+ // provenance, so it keeps the pre-existing conservative default,
277
+ // `UNKNOWN_IMPORT_KIND`, which `getCycles` still treats as load-order
278
+ // (AC4 of flow 140 — never silently exclude a real cycle when we
279
+ // cannot tell).
280
+ // A specifier the transpiler DID see keeps its real kind; the fallback
281
+ // never overrides it.
266
282
  for (const specifier of fallback) {
267
283
  if (!kindBySpecifier.has(specifier)) {
268
- kindBySpecifier.set(specifier, UNKNOWN_IMPORT_KIND);
284
+ kindBySpecifier.set(specifier, scanResult.succeeded ? TYPE_ONLY_IMPORT_KIND : UNKNOWN_IMPORT_KIND);
269
285
  }
270
286
  }
271
287
 
@@ -276,19 +292,40 @@ function extractImportRecords(content: string, language: string): ImportRecord[]
276
292
 
277
293
  type ScannedImport = { specifier: string; kind: TranspilerImportKind };
278
294
 
279
- function scanImportsOrEmpty(content: string): ScannedImport[] {
295
+ // `succeeded: false` means the transpiler could not even parse the file —
296
+ // distinct from `succeeded: true, imports: []` (a parseable file with no
297
+ // scannable imports, e.g. one containing only type-only imports). The caller
298
+ // needs that distinction to tell "known type-only" apart from "genuinely
299
+ // unknown provenance" (AFC-11, flow 234) when a specifier turns up only in
300
+ // the regex fallback.
301
+ type ScanResult = { succeeded: boolean; imports: ScannedImport[] };
302
+
303
+ // T19 finding 2 (flow 234 review): the `tsx` loader was hardcoded for every
304
+ // TS/JS file. Under `tsx`, ordinary valid `.ts` syntax that collides with JSX
305
+ // grammar throws — a generic arrow function (`<T>(x: T): T => x`) and an
306
+ // angle-bracket cast (`<string>value`) are both ambiguous with a JSX opening
307
+ // tag. `.ts` (never `.tsx`) is the only extension that can carry that
308
+ // syntax AND can never legally contain real JSX, so it is the only one safe
309
+ // to parse with JSX grammar disabled. `.tsx`/`.jsx` (and plain `.js`, which
310
+ // may itself contain JSX) keep the original `tsx` loader unchanged.
311
+ function loaderForFile(filePath: string): "ts" | "tsx" {
312
+ return filePath.endsWith(".ts") ? "ts" : "tsx";
313
+ }
314
+
315
+ function scanImportsOrEmpty(content: string, filePath: string): ScanResult {
280
316
  try {
281
- const scanner = new Bun.Transpiler({ loader: "tsx" });
282
- return scanner
317
+ const scanner = new Bun.Transpiler({ loader: loaderForFile(filePath) });
318
+ const imports = scanner
283
319
  .scanImports(content)
284
320
  .filter(
285
321
  (entry): entry is { path: string; kind: TranspilerImportKind } =>
286
322
  typeof entry.path === "string" && entry.path.length > 0,
287
323
  )
288
324
  .map((entry) => ({ specifier: entry.path, kind: entry.kind }));
325
+ return { succeeded: true, imports };
289
326
  } catch {
290
327
  // Unparseable source ⇒ the regex fallback alone still yields the imports.
291
- return [];
328
+ return { succeeded: false, imports: [] };
292
329
  }
293
330
  }
294
331
 
@@ -311,7 +348,7 @@ function extractImportSpecifiersFallback(content: string): string[] {
311
348
 
312
349
  // Java patterns (import com.example.Class;)
313
350
  const javaPatterns = [
314
- /\bimport\s+(?:static\s+)?([a-zA-Z_][a-zA-Z0-9_\.]*(?:\.\*)?)\s*;/g,
351
+ /\bimport\s+(?:static\s+)?([a-zA-Z_][a-zA-Z0-9_.]*(?:.*)?)\s*;/g,
315
352
  ];
316
353
 
317
354
  // Python patterns (import module, from module import name).
@@ -321,9 +358,9 @@ function extractImportSpecifiersFallback(content: string): string[] {
321
358
  // before because the module regex required a leading letter; the third
322
359
  // pattern captures the leading-dot forms.
323
360
  const pythonPatterns = [
324
- /^[ \t]*import\s+([a-zA-Z_][a-zA-Z0-9_\.]*)/gm,
325
- /\bfrom\s+([a-zA-Z_][a-zA-Z0-9_\.]*)\s+import/g,
326
- /\bfrom\s+(\.+[a-zA-Z0-9_\.]*)\s+import/g,
361
+ /^[ \t]*import\s+([a-zA-Z_][a-zA-Z0-9_.]*)/gm,
362
+ /\bfrom\s+([a-zA-Z_][a-zA-Z0-9_.]*)\s+import/g,
363
+ /\bfrom\s+(\.+[a-zA-Z0-9_.]*)\s+import/g,
327
364
  ];
328
365
 
329
366
  for (const pattern of [...jsPatterns, ...javaPatterns, ...pythonPatterns]) {
@@ -124,7 +124,7 @@ test("AC4.3 — capability ENABLED but unavailable: exactly one warn, regex path
124
124
  return true;
125
125
  }) as typeof process.stderr.write;
126
126
 
127
- let exitCodeBefore = process.exitCode;
127
+ const exitCodeBefore = process.exitCode;
128
128
  try {
129
129
  await buildGraph(root);
130
130
  } finally {
@@ -155,15 +155,66 @@ test("AC3 — the same two-file cycle formed by await import() is NOT reported a
155
155
  });
156
156
 
157
157
  // ---------------------------------------------------------------------------
158
- // AC4 — an edge found only by the regex fallback (never seen by scanImports)
159
- // is marked with an explicit unknown/static marker, never inferred dynamic.
160
- // A type-only import is the real-world case: `Bun.Transpiler#scanImports`
161
- // erases `import type {...}` entirely, so it reaches the graph only through
162
- // `extractImportSpecifiersFallback`.
158
+ // AC4 (flow 140) — an edge found only by the regex fallback (never seen by
159
+ // scanImports) is marked with an explicit unknown/static marker, never
160
+ // inferred dynamic.
161
+ //
162
+ // SUPERSEDED for the TS/JS type-only case by AFC-11 (flow 234, below): a
163
+ // fallback-only edge whose file the transpiler DID successfully scan is now
164
+ // known to be type-only, not merely "unknown" — it gets its own
165
+ // `TYPE_ONLY_IMPORT_KIND` so cycle detection can exclude it while impact
166
+ // analysis keeps it. The Java case is untouched: Java is routed straight to
167
+ // the fallback (`extractImportRecords` never calls `scanImports` for it), so
168
+ // its provenance is genuinely unknown and it keeps `unknown-static`.
163
169
  // ---------------------------------------------------------------------------
164
170
 
165
- test("AC4 — a fallback-only edge (type-only import) is marked unknown-static, never inferred dynamic", async () => {
166
- const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-ac4");
171
+ test("AC4 (flow 140, still applies to Java) — a fallback-only Java edge is marked unknown-static, never inferred dynamic", async () => {
172
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-java");
173
+ await reset(root);
174
+ const javaRoot = path.join(root, "src", "main", "java", "com", "example");
175
+ await mkdir(javaRoot, { recursive: true });
176
+ await writeFile(
177
+ path.join(javaRoot, "Consumer.java"),
178
+ "package com.example;\nimport com.example.Model;\npublic class Consumer {}\n",
179
+ );
180
+ await writeFile(path.join(javaRoot, "Model.java"), "package com.example;\npublic class Model {}\n");
181
+
182
+ await buildGraph(root);
183
+ const graph = await loadGraph(root);
184
+ const edge = graph.edges.find(
185
+ (item) =>
186
+ item.from === "src/main/java/com/example/Consumer.java" &&
187
+ item.to === "src/main/java/com/example/Model.java",
188
+ );
189
+
190
+ expect(edge).toBeDefined();
191
+ expect(edge?.importKind).toBe("unknown-static");
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // AFC-11 (flow 234, phase 2) — frozen AC4:
196
+ // "Type-only цикл не попадает в runtime список; mixed import с
197
+ // runtime-частью попадает; consumer типа виден в impact; цикл сам по себе
198
+ // не блокирует gate."
199
+ //
200
+ // Reproduced first (see the task report for the exact repro run), then fixed:
201
+ // a type-only cycle (`import type` on both sides) built as `unknown-static`
202
+ // and was reported by `getCycles` as a real runtime cycle, because
203
+ // `unknown-static` was — and for Java/unparseable TS still is — treated as
204
+ // load-order. The fix gives TS/JS type-only edges their own kind,
205
+ // `TYPE_ONLY_IMPORT_KIND` ("type-only"), which `getCycles` (query.ts)
206
+ // excludes from load-order adjacency the same way it already excludes
207
+ // `dynamic-import`, while every impact-analysis consumer (`getOrphans`,
208
+ // `getAffected`, `computeAffected`) is untouched because they read
209
+ // `edge.kind`, not `edge.importKind`.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ // --- requirement 1 + classification table: the four "is it type-only?"
213
+ // spellings this task must distinguish. Each RED-failed against the
214
+ // pre-fix code with `edge?.importKind === "unknown-static"` (see report).
215
+
216
+ test("AFC-11 req1 — `import type { X } from './m'` (whole-statement) is classified type-only", async () => {
217
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-import-type-whole");
167
218
  await reset(root);
168
219
  await mkdir(path.join(root, "src"), { recursive: true });
169
220
  await writeFile(
@@ -176,30 +227,299 @@ test("AC4 — a fallback-only edge (type-only import) is marked unknown-static,
176
227
  const graph = await loadGraph(root);
177
228
  const edge = graph.edges.find((item) => item.from === "src/consumer.ts" && item.to === "src/types.ts");
178
229
 
179
- expect(edge).toBeDefined();
180
- expect(edge?.importKind).toBe("unknown-static");
181
- expect(edge?.importKind).not.toBe("dynamic-import");
230
+ expect(edge?.importKind).toBe("type-only");
182
231
  });
183
232
 
184
- test("AC4 — Java imports (fallback-only language) are marked unknown-static, never dynamic", async () => {
185
- const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-java");
233
+ test("AFC-11 req1 — `import { type X } from './m'` (inline, all-type) is classified type-only", async () => {
234
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-import-type-inline");
186
235
  await reset(root);
187
- const javaRoot = path.join(root, "src", "main", "java", "com", "example");
188
- await mkdir(javaRoot, { recursive: true });
236
+ await mkdir(path.join(root, "src"), { recursive: true });
189
237
  await writeFile(
190
- path.join(javaRoot, "Consumer.java"),
191
- "package com.example;\nimport com.example.Model;\npublic class Consumer {}\n",
238
+ path.join(root, "src", "consumer.ts"),
239
+ "import { type Shape } from './types';\nexport const consumer: Shape = { ok: true } as Shape;\n",
192
240
  );
193
- await writeFile(path.join(javaRoot, "Model.java"), "package com.example;\npublic class Model {}\n");
241
+ await writeFile(path.join(root, "src", "types.ts"), "export interface Shape { ok: boolean }\n");
194
242
 
195
243
  await buildGraph(root);
196
244
  const graph = await loadGraph(root);
197
- const edge = graph.edges.find(
198
- (item) =>
199
- item.from === "src/main/java/com/example/Consumer.java" &&
200
- item.to === "src/main/java/com/example/Model.java",
245
+ const edge = graph.edges.find((item) => item.from === "src/consumer.ts" && item.to === "src/types.ts");
246
+
247
+ expect(edge?.importKind).toBe("type-only");
248
+ });
249
+
250
+ test("AFC-11 req1 — `export type { X } from './m'` (whole-statement re-export) is classified type-only", async () => {
251
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-export-type-whole");
252
+ await reset(root);
253
+ await mkdir(path.join(root, "src"), { recursive: true });
254
+ await writeFile(path.join(root, "src", "types.ts"), "export interface Shape { ok: boolean }\n");
255
+ await writeFile(path.join(root, "src", "reexport.ts"), "export type { Shape } from './types';\n");
256
+
257
+ await buildGraph(root);
258
+ const graph = await loadGraph(root);
259
+ const edge = graph.edges.find((item) => item.from === "src/reexport.ts" && item.to === "src/types.ts");
260
+
261
+ expect(edge?.importKind).toBe("type-only");
262
+ });
263
+
264
+ test("AFC-11 req1 — `export { type X } from './m'` (inline re-export, all-type) is classified type-only", async () => {
265
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-export-type-inline");
266
+ await reset(root);
267
+ await mkdir(path.join(root, "src"), { recursive: true });
268
+ await writeFile(path.join(root, "src", "types.ts"), "export interface Shape { ok: boolean }\n");
269
+ await writeFile(path.join(root, "src", "reexport.ts"), "export { type Shape } from './types';\n");
270
+
271
+ await buildGraph(root);
272
+ const graph = await loadGraph(root);
273
+ const edge = graph.edges.find((item) => item.from === "src/reexport.ts" && item.to === "src/types.ts");
274
+
275
+ expect(edge?.importKind).toBe("type-only");
276
+ });
277
+
278
+ // --- requirement 1: the actual reported symptom — a two-file cycle formed
279
+ // only by type-only edges must not show up in `getCycles`.
280
+
281
+ test("AFC-11 req1 — a two-file cycle formed only by `import type` on both sides is NOT reported", async () => {
282
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-cycle-import-type");
283
+ await reset(root);
284
+ await mkdir(path.join(root, "src"), { recursive: true });
285
+ await writeFile(
286
+ path.join(root, "src", "a.ts"),
287
+ "import type { BType } from './b';\nexport type AType = { b?: BType };\n",
288
+ );
289
+ await writeFile(
290
+ path.join(root, "src", "b.ts"),
291
+ "import type { AType } from './a';\nexport type BType = { a?: AType };\n",
201
292
  );
202
293
 
203
- expect(edge).toBeDefined();
294
+ await buildGraph(root);
295
+ const graph = await loadGraph(root);
296
+ const cycles = getCycles(graph);
297
+
298
+ expect(cycles.some((cycle) => cycle.includes("src/a.ts") && cycle.includes("src/b.ts"))).toBe(false);
299
+ });
300
+
301
+ test("AFC-11 req1 — a two-file cycle formed only by `export type … from` on both sides is NOT reported", async () => {
302
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-cycle-export-type");
303
+ await reset(root);
304
+ await mkdir(path.join(root, "src"), { recursive: true });
305
+ await writeFile(path.join(root, "src", "a.ts"), "export type { BType } from './b';\nexport type AType = {};\n");
306
+ await writeFile(path.join(root, "src", "b.ts"), "export type { AType } from './a';\nexport type BType = {};\n");
307
+
308
+ await buildGraph(root);
309
+ const graph = await loadGraph(root);
310
+ const cycles = getCycles(graph);
311
+
312
+ expect(cycles.some((cycle) => cycle.includes("src/a.ts") && cycle.includes("src/b.ts"))).toBe(false);
313
+ });
314
+
315
+ // --- a fifth, non-type-erasing spelling: a plain value import whose binding
316
+ // is used only in a type position at the use site. `import { x } from './m'`
317
+ // carries no `type` keyword anywhere, so the transpiler (this repo's
318
+ // tsconfig sets neither `importsNotUsedAsValues` nor `verbatimModuleSyntax`,
319
+ // so TypeScript's/Bun's default elision applies — see the report) does NOT
320
+ // erase it: erasure here is purely syntactic (was `type` written?), never a
321
+ // usage analysis (is the binding read only as a type elsewhere?). This test
322
+ // guards against a fix that tries to be "smarter" than the transpiler by
323
+ // inferring type-only-ness from usage — it must stay a real runtime edge.
324
+
325
+ test("`import { x } from './m'` used only in a type position stays import-statement (no usage-based erasure)", async () => {
326
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-value-used-as-type");
327
+ await reset(root);
328
+ await mkdir(path.join(root, "src"), { recursive: true });
329
+ await writeFile(
330
+ path.join(root, "src", "consumer.ts"),
331
+ "import { value } from './lib';\nexport const y: typeof value = value;\n",
332
+ );
333
+ await writeFile(path.join(root, "src", "lib.ts"), "export const value = { ok: true };\n");
334
+
335
+ await buildGraph(root);
336
+ const graph = await loadGraph(root);
337
+ const edge = graph.edges.find((item) => item.from === "src/consumer.ts" && item.to === "src/lib.ts");
338
+
339
+ expect(edge?.importKind).toBe("import-statement");
340
+ });
341
+
342
+ // --- requirement 2 (over-correction guard) — a mixed import (one runtime
343
+ // specifier alongside a type specifier) still produces a real load-order
344
+ // edge, and a cycle closed through it IS reported. This is the test that
345
+ // would fail if requirement 1 were implemented too bluntly (e.g. by
346
+ // excluding every edge whose statement merely CONTAINS the word `type`).
347
+
348
+ test("AFC-11 req2 — `import { x, type Y } from './m'` (mixed) is classified import-statement, a real runtime edge", async () => {
349
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-mixed-classification");
350
+ await reset(root);
351
+ await mkdir(path.join(root, "src"), { recursive: true });
352
+ await writeFile(
353
+ path.join(root, "src", "consumer.ts"),
354
+ "import { value, type Shape } from './lib';\nexport const consumer: Shape = value;\n",
355
+ );
356
+ await writeFile(
357
+ path.join(root, "src", "lib.ts"),
358
+ "export interface Shape { ok: boolean }\nexport const value = { ok: true };\n",
359
+ );
360
+
361
+ await buildGraph(root);
362
+ const graph = await loadGraph(root);
363
+ const edge = graph.edges.find((item) => item.from === "src/consumer.ts" && item.to === "src/lib.ts");
364
+
365
+ expect(edge?.importKind).toBe("import-statement");
366
+ });
367
+
368
+ test("AFC-11 req2 (the over-correction guard) — a two-file cycle where both directions are mixed imports IS reported", async () => {
369
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-cycle-mixed-both");
370
+ await reset(root);
371
+ await mkdir(path.join(root, "src"), { recursive: true });
372
+ await writeFile(
373
+ path.join(root, "src", "a.ts"),
374
+ "import { b, type BType } from './b';\nexport type AType = { b?: BType };\nexport const a = () => b();\n",
375
+ );
376
+ await writeFile(
377
+ path.join(root, "src", "b.ts"),
378
+ "import { a, type AType } from './a';\nexport type BType = { a?: AType };\nexport const b = () => a();\n",
379
+ );
380
+
381
+ await buildGraph(root);
382
+ const graph = await loadGraph(root);
383
+ const cycles = getCycles(graph);
384
+
385
+ expect(cycles.some((cycle) => cycle.includes("src/a.ts") && cycle.includes("src/b.ts"))).toBe(true);
386
+ });
387
+
388
+ // --- requirement 4 — a detected cycle is information only; it never throws,
389
+ // never sets a failure flag, and the JSON `getCycles` returns is exactly what
390
+ // a caller (CLI/MCP) renders as a plain list. There is no "gate" primitive in
391
+ // this module's return type to fail: proving that is proving there is no
392
+ // exception and no truthy/falsy "blocked" field mixed into the result.
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // T19 finding 2 (flow 234 review) — `extractImportRecords` hardcoded the
396
+ // `tsx` transpiler loader for every `.ts` file. Under `tsx`, valid TypeScript
397
+ // syntax that collides with JSX grammar throws: a generic arrow function
398
+ // (`<T>(x: T): T => x`) and an angle-bracket cast (`<string>value`) are both
399
+ // ambiguous with a JSX opening tag. When the scan throws, EVERY specifier in
400
+ // the file falls back to the regex extractor with `scanResult.succeeded ===
401
+ // false`, so a type-only cycle between two such files was still classified
402
+ // `UNKNOWN_IMPORT_KIND` — which `getCycles` treats as load-order — defeating
403
+ // AC4 clause 1 on a real, constructible TypeScript input. Reproduced directly
404
+ // against `src/tui/shell-chrome.ts` / `src/sac/fwk-service.test.ts` (both
405
+ // throw under the `tsx` loader, both parse under `ts`) — see the task report.
406
+ // Fixed by choosing the loader from the file extension (`ts` for `.ts`,
407
+ // `tsx` for `.tsx`/`.jsx`), so a `.ts` file — which can never legally contain
408
+ // JSX — is parsed with JSX grammar disabled.
409
+ // ---------------------------------------------------------------------------
410
+
411
+ test("T19 finding 2 — a generic arrow function no longer breaks the scan for a .ts file", async () => {
412
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-t19-generic-arrow");
413
+ await reset(root);
414
+ await mkdir(path.join(root, "src"), { recursive: true });
415
+ await writeFile(
416
+ path.join(root, "src", "identity.ts"),
417
+ "import { helper } from './helper';\nexport const identity = <T>(x: T): T => x;\nexport const used = helper();\n",
418
+ );
419
+ await writeFile(path.join(root, "src", "helper.ts"), "export const helper = () => 1;\n");
420
+
421
+ await buildGraph(root);
422
+ const graph = await loadGraph(root);
423
+ const edge = graph.edges.find((item) => item.from === "src/identity.ts" && item.to === "src/helper.ts");
424
+
425
+ // Before the fix: the `tsx` scan of identity.ts throws on `<T>(x: T)`, the
426
+ // whole file falls back to the regex extractor, and this edge is
427
+ // classified `unknown-static`, not the real `import-statement`.
428
+ expect(edge?.importKind).toBe("import-statement");
429
+ });
430
+
431
+ test("T19 finding 2 — an angle-bracket cast no longer breaks the scan for a .ts file", async () => {
432
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-t19-angle-cast");
433
+ await reset(root);
434
+ await mkdir(path.join(root, "src"), { recursive: true });
435
+ await writeFile(
436
+ path.join(root, "src", "cast.ts"),
437
+ "import { helper } from './helper';\nexport const cast = (value: unknown) => <string>value;\nexport const used = helper();\n",
438
+ );
439
+ await writeFile(path.join(root, "src", "helper.ts"), "export const helper = () => 1;\n");
440
+
441
+ await buildGraph(root);
442
+ const graph = await loadGraph(root);
443
+ const edge = graph.edges.find((item) => item.from === "src/cast.ts" && item.to === "src/helper.ts");
444
+
445
+ expect(edge?.importKind).toBe("import-statement");
446
+ });
447
+
448
+ test("T19 finding 2 — a type-only cycle between two files that also use a generic arrow / angle-bracket cast is NOT reported as a runtime cycle", async () => {
449
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-t19-cycle-jsx-collision");
450
+ await reset(root);
451
+ await mkdir(path.join(root, "src"), { recursive: true });
452
+ await writeFile(
453
+ path.join(root, "src", "a.ts"),
454
+ [
455
+ "import type { BType } from './b';",
456
+ "export type AType = { b?: BType };",
457
+ "export const identity = <T>(x: T): T => x;",
458
+ "",
459
+ ].join("\n"),
460
+ );
461
+ await writeFile(
462
+ path.join(root, "src", "b.ts"),
463
+ [
464
+ "import type { AType } from './a';",
465
+ "export type BType = { a?: AType };",
466
+ "export const cast = (value: unknown) => <string>value;",
467
+ "",
468
+ ].join("\n"),
469
+ );
470
+
471
+ await buildGraph(root);
472
+ const graph = await loadGraph(root);
473
+
474
+ const edgeAB = graph.edges.find((item) => item.from === "src/a.ts" && item.to === "src/b.ts");
475
+ const edgeBA = graph.edges.find((item) => item.from === "src/b.ts" && item.to === "src/a.ts");
476
+ // Before the fix, both scans throw (the file collides with JSX grammar
477
+ // under `tsx`), so both edges fall back to `unknown-static` — which
478
+ // `getCycles` treats as load-order — instead of the real `type-only`.
479
+ expect(edgeAB?.importKind).toBe("type-only");
480
+ expect(edgeBA?.importKind).toBe("type-only");
481
+
482
+ const cycles = getCycles(graph);
483
+ expect(cycles.some((cycle) => cycle.includes("src/a.ts") && cycle.includes("src/b.ts"))).toBe(false);
484
+ });
485
+
486
+ // A genuinely unparseable `.ts` file must still classify as unknown — the
487
+ // fix must not weaken AC4 (flow 140) by treating every scan failure as
488
+ // type-only. Unbalanced syntax throws under BOTH the `ts` and `tsx` loaders
489
+ // (verified directly against `Bun.Transpiler` — see the task report), so this
490
+ // proves the retry/extension-selection does not paper over a real parse
491
+ // failure.
492
+ test("T19 finding 2 (both directions) — a genuinely unparseable .ts file still classifies its edges as unknown-static", async () => {
493
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-t19-unparseable-ts");
494
+ await reset(root);
495
+ await mkdir(path.join(root, "src"), { recursive: true });
496
+ await writeFile(
497
+ path.join(root, "src", "broken.ts"),
498
+ "import { helper } from './helper';\nexport const broken = {\n",
499
+ );
500
+ await writeFile(path.join(root, "src", "helper.ts"), "export const helper = () => 1;\n");
501
+
502
+ await buildGraph(root);
503
+ const graph = await loadGraph(root);
504
+ const edge = graph.edges.find((item) => item.from === "src/broken.ts" && item.to === "src/helper.ts");
505
+
204
506
  expect(edge?.importKind).toBe("unknown-static");
205
507
  });
508
+
509
+ test("AFC-11 req4 — a real runtime cycle is reported without throwing, as plain data (not a gate failure)", async () => {
510
+ const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-afc11-gate");
511
+ await reset(root);
512
+ await mkdir(path.join(root, "src"), { recursive: true });
513
+ await writeFile(path.join(root, "src", "a.ts"), "import { b } from './b';\nexport const a = () => b();\n");
514
+ await writeFile(path.join(root, "src", "b.ts"), "import { a } from './a';\nexport const b = () => a();\n");
515
+
516
+ await buildGraph(root);
517
+ const graph = await loadGraph(root);
518
+
519
+ expect(() => getCycles(graph)).not.toThrow();
520
+ const cycles = getCycles(graph);
521
+ expect(Array.isArray(cycles)).toBe(true);
522
+ expect(cycles.length).toBeGreaterThan(0);
523
+ // The return type is `string[][]` — there is no boolean/gate field on it to
524
+ // assert "false" for; the absence of one IS the requirement.
525
+ });