@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrciphersmith/keryx",
|
|
3
|
-
"version": "0.2.
|
|
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": "
|
|
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.
|
|
70
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
60
71
|
"@opentui/core": "^0.4.5",
|
|
61
|
-
"web-tree-sitter": "^0.22.
|
|
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
|
-
"
|
|
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
|
+
});
|
package/src/gdgraph/build.ts
CHANGED
|
@@ -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
|
|
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
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
-
|
|
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:
|
|
282
|
-
|
|
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_
|
|
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_
|
|
325
|
-
/\bfrom\s+([a-zA-Z_][a-zA-Z0-9_
|
|
326
|
-
/\bfrom\s+(\.+[a-zA-Z0-9_
|
|
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
|
-
|
|
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
|
|
159
|
-
// is marked with an explicit unknown/static marker, never
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
|
166
|
-
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-
|
|
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).
|
|
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("
|
|
185
|
-
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-
|
|
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
|
-
|
|
188
|
-
await mkdir(javaRoot, { recursive: true });
|
|
236
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
189
237
|
await writeFile(
|
|
190
|
-
path.join(
|
|
191
|
-
"
|
|
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(
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
+
});
|