@holmes-lab/holmes-kit 0.18.0 → 0.19.2

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +3 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +22 -0
  5. package/dist/holmes/cli/agents.js +76 -1
  6. package/dist/holmes/cli/approve.js +6 -1
  7. package/dist/holmes/cli/doctor.d.ts +36 -1
  8. package/dist/holmes/cli/doctor.js +182 -35
  9. package/dist/holmes/cli/index.js +7 -1
  10. package/dist/holmes/cli/init.js +12 -0
  11. package/dist/holmes/cli/native-deps.d.ts +65 -0
  12. package/dist/holmes/cli/native-deps.js +131 -0
  13. package/dist/holmes/cli/release-docs.d.ts +27 -0
  14. package/dist/holmes/cli/release-docs.js +68 -0
  15. package/dist/holmes/cpg/arch-observe.d.ts +15 -0
  16. package/dist/holmes/cpg/arch-observe.js +19 -0
  17. package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
  18. package/dist/holmes/cpg/cpg-scanner.js +27 -3
  19. package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
  20. package/dist/holmes/cpg/cycle-detect.js +251 -0
  21. package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
  22. package/dist/holmes/cpg/cycle-observation.js +146 -0
  23. package/dist/holmes/cpg/scan-cache.d.ts +1 -1
  24. package/dist/holmes/cpg/scanned-file.d.ts +36 -0
  25. package/dist/holmes/cpg/scanned-file.js +2 -0
  26. package/dist/holmes/governance/approval-queue.d.ts +23 -4
  27. package/dist/holmes/governance/approval-queue.js +44 -6
  28. package/dist/holmes/governance/constitution.d.ts +20 -0
  29. package/dist/holmes/governance/constitution.js +17 -0
  30. package/dist/holmes/governance/ledger-store.d.ts +9 -0
  31. package/dist/holmes/governance/ledger-store.js +47 -0
  32. package/dist/holmes/governance/provenance-chain.d.ts +16 -1
  33. package/dist/holmes/governance/provenance-chain.js +5 -3
  34. package/dist/holmes/hooks/pre-tool-use.js +3 -1
  35. package/dist/holmes/hooks/stop.d.ts +29 -0
  36. package/dist/holmes/hooks/stop.js +119 -3
  37. package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
  38. package/dist/holmes/mcp/defuse-bound.js +8 -0
  39. package/dist/holmes/mcp/handlers.d.ts +7 -0
  40. package/dist/holmes/mcp/handlers.js +132 -6
  41. package/dist/holmes/mcp/history-admission.d.ts +15 -0
  42. package/dist/holmes/mcp/history-admission.js +37 -0
  43. package/dist/holmes/mcp/maintenance-analyze.d.ts +45 -0
  44. package/dist/holmes/mcp/maintenance-analyze.js +117 -9
  45. package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
  46. package/dist/holmes/mcp/maintenance-evidence.js +71 -4
  47. package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
  48. package/dist/holmes/project/install-scripts-policy.js +131 -0
  49. package/dist/holmes/project/npx-bin.d.ts +6 -0
  50. package/dist/holmes/project/npx-bin.js +10 -0
  51. package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
  52. package/dist/holmes/review/evaluation-metrics.js +18 -1
  53. package/dist/holmes/review/failed-test-names.d.ts +19 -0
  54. package/dist/holmes/review/failed-test-names.js +43 -0
  55. package/dist/holmes/review/paired-power.d.ts +14 -0
  56. package/dist/holmes/review/paired-power.js +57 -0
  57. package/dist/holmes/review/replay-corpus.d.ts +11 -0
  58. package/dist/holmes/review/replay-corpus.js +34 -0
  59. package/dist/holmes/review/run-replay.d.ts +23 -0
  60. package/dist/holmes/review/run-replay.js +90 -4
  61. package/dist/holmes/review/symbol-truth.d.ts +14 -0
  62. package/dist/holmes/review/symbol-truth.js +23 -0
  63. package/dist/holmes/review/test-runner.d.ts +27 -0
  64. package/dist/holmes/review/test-runner.js +59 -3
  65. package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
  66. package/dist/holmes/rtm/defuse-symbols.js +91 -0
  67. package/dist/holmes/rtm/incremental.js +5 -0
  68. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  69. package/dist/holmes/rtm/rtm-builder.js +32 -5
  70. package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
  71. package/dist/holmes/rtm/rtm-graph.js +13 -0
  72. package/dist/holmes/spec/legacy-fields.d.ts +2 -0
  73. package/dist/holmes/spec/legacy-fields.js +9 -0
  74. package/dist/holmes/spec/legacy-format.d.ts +1 -1
  75. package/dist/holmes/spec/legacy-format.js +4 -1
  76. package/dist/holmes/spec/spec-parser.js +5 -3
  77. package/docs/install-guide.md +54 -5
  78. package/package.json +4 -1
  79. package/playbooks/author-slice/PLAYBOOK.md +14 -0
  80. package/playbooks/publish/PLAYBOOK.md +32 -0
  81. package/playbooks/tdd-slice/PLAYBOOK.md +14 -0
@@ -0,0 +1,15 @@
1
+ export interface ArchObservation {
2
+ path: string;
3
+ lines: number | null;
4
+ symbols: number;
5
+ longestFunction: number | null;
6
+ fanIn: number;
7
+ fanOut: number;
8
+ }
9
+ export declare function architectureObservation(files: readonly string[], symbolsByFile: ReadonlyMap<string, Array<{
10
+ startLine: number;
11
+ endLine: number;
12
+ }>>, importEdges: readonly {
13
+ from: string;
14
+ to: string;
15
+ }[], readLines: (file: string) => number | null): ArchObservation[];
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.architectureObservation = architectureObservation;
4
+ function architectureObservation(files, symbolsByFile, importEdges, readLines) {
5
+ return [...files].sort().map((path) => {
6
+ const syms = symbolsByFile.get(path) ?? [];
7
+ const spans = syms.map((s) => s.endLine - s.startLine + 1);
8
+ return {
9
+ path,
10
+ // null, not 0: a file we could not read is a different fact from an empty file, and
11
+ // collapsing the two is how a report starts lying quietly.
12
+ lines: readLines(path),
13
+ symbols: syms.length,
14
+ longestFunction: spans.length === 0 ? null : Math.max(...spans),
15
+ fanIn: importEdges.filter((e) => e.to === path).length,
16
+ fanOut: importEdges.filter((e) => e.from === path).length,
17
+ };
18
+ });
19
+ }
@@ -1,39 +1,6 @@
1
- import { LanguageParser, CodeSymbol, CodeEdge, DataFlowFacts } from './language-parser';
2
- export interface ScannedFile {
3
- path: string;
4
- /** Repo-root-relative POSIX path — provenance key used by RtmGraph.removeBySource. */
5
- sourcePath: string;
6
- symbols: CodeSymbol[];
7
- /**
8
- * Call/import relations recovered from this file, with `from` a qualified name defined HERE and
9
- * `to` a bare callee name or module path. Resolution to graph node ids happens in rtm-builder,
10
- * where the whole scan is visible; a single file cannot know where a callee lives.
11
- *
12
- * Optional so a cache entry written by an older scanner still loads — such an entry simply carries
13
- * no edges, which degrades to the previous anchor-only behaviour rather than crashing.
14
- */
15
- edges?: CodeEdge[];
16
- /**
17
- * Def-use facts, present ONLY when the scanner was constructed with `{ dataflow: true }` AND the
18
- * file's language has a data-flow walk.
19
- *
20
- * @implements A-SPEC-140.1
21
- * Opt-in by measurement, not taste: the scan already parses each file twice (symbols, edges), and
22
- * an unconditional third parse would regress the REQ-139 cold-scan baseline by ~50% to produce a
23
- * signal only the taint pass reads. `undefined` therefore means "not extracted" — for the option
24
- * being off, for a language with no walk, or for a cache entry from an older writer. It never
25
- * means "extracted, found nothing"; that is `{defs:[],calls:[],params:[],returns:[]}`.
26
- */
27
- dataflow?: DataFlowFacts;
28
- implementsSpecs: string[];
29
- /**
30
- * `@implements` tags shaped like a well-formed governed spec id (REQ/H-SPEC/
31
- * C-SPEC/T-SPEC-NNN) instead of an A-SPEC anchor — a wrong-kind anchor that
32
- * would otherwise be silently dropped (audit finding D). Populated
33
- * alongside `implementsSpecs`, never overlapping with it.
34
- */
35
- unanchoredImplements: string[];
36
- }
1
+ import { LanguageParser } from './language-parser';
2
+ import type { ScannedFile } from './scanned-file';
3
+ export type { ScannedFile } from './scanned-file';
37
4
  /**
38
5
  * Directories that never contain hand-written source for the project under analysis.
39
6
  *
@@ -71,6 +38,13 @@ export declare const SKIP: Set<string>;
71
38
  */
72
39
  export declare function isUnderSkippedDir(p: unknown): boolean;
73
40
  /** Single source of truth for which file extensions CpgScanner ingests (REQ-124 gate 2). */
41
+ /**
42
+ * Extension -> grammar. Exported because a SECOND consumer now reads data flow (the def-use symbol
43
+ * lane), and a second copy of this rule is a second thing to drift — the first wiring omitted the
44
+ * language entirely and parsed Python as TypeScript, which cost the other corpus 0.2376 -> 0.1741
45
+ * on the symbol axis before the instrument caught it.
46
+ */
47
+ export declare function langForPath(name: string): 'python' | 'csharp' | 'java' | 'go' | 'rust' | 'cpp' | 'tsx' | 'typescript';
74
48
  export declare const SCANNABLE_EXTENSIONS: readonly [".ts", ".mts", ".cts", ".tsx", ".jsx", ".js", ".mjs", ".cjs", ".py", ".cs", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h"];
75
49
  export { TEST_FILE_PATTERNS, isTestFile } from './test-files';
76
50
  export declare class CpgScanner {
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CpgScanner = exports.isTestFile = exports.TEST_FILE_PATTERNS = exports.SCANNABLE_EXTENSIONS = exports.SKIP = void 0;
37
37
  exports.isUnderSkippedDir = isUnderSkippedDir;
38
+ exports.langForPath = langForPath;
38
39
  // @implements A-SPEC-208
39
40
  const fs = __importStar(require("node:fs"));
40
41
  const path = __importStar(require("node:path"));
@@ -109,6 +110,28 @@ function isVendorDir(dir) {
109
110
  // Anchor parsing (incl. wrong-kind detection) moved to the shared comma-list parser in
110
111
  // rtm/anchor-ids (A-SPEC-503.1) — the local single-capture regexes dropped every id after a comma.
111
112
  /** Single source of truth for which file extensions CpgScanner ingests (REQ-124 gate 2). */
113
+ // @implements A-SPEC-573.4
114
+ /**
115
+ * Extension -> grammar. Exported because a SECOND consumer now reads data flow (the def-use symbol
116
+ * lane), and a second copy of this rule is a second thing to drift — the first wiring omitted the
117
+ * language entirely and parsed Python as TypeScript, which cost the other corpus 0.2376 -> 0.1741
118
+ * on the symbol axis before the instrument caught it.
119
+ */
120
+ function langForPath(name) {
121
+ if (/\.py$/.test(name))
122
+ return 'python';
123
+ if (/\.cs$/.test(name))
124
+ return 'csharp';
125
+ if (/\.java$/.test(name))
126
+ return 'java';
127
+ if (/\.go$/.test(name))
128
+ return 'go';
129
+ if (/\.rs$/.test(name))
130
+ return 'rust';
131
+ if (/\.(cpp|cc|cxx|hpp|hh|h)$/.test(name))
132
+ return 'cpp';
133
+ return TSX_GRAMMAR_RE.test(name) ? 'tsx' : 'typescript';
134
+ }
112
135
  exports.SCANNABLE_EXTENSIONS = ['.ts', '.mts', '.cts', '.tsx', '.jsx', '.js', '.mjs', '.cjs', '.py', '.cs', '.java', '.go', '.rs', '.cpp', '.cc', '.cxx', '.hpp', '.hh', '.h'];
113
136
  const SCANNABLE_RE = /\.(ts|mts|cts|tsx|jsx|js|mjs|cjs|py|cs|java|go|rs|cpp|cc|cxx|hpp|hh|h)$/;
114
137
  // The test-file table moved to ./test-files, so the PreToolUse phase gate can read the SAME rules
@@ -118,6 +141,8 @@ var test_files_1 = require("./test-files");
118
141
  Object.defineProperty(exports, "TEST_FILE_PATTERNS", { enumerable: true, get: function () { return test_files_1.TEST_FILE_PATTERNS; } });
119
142
  Object.defineProperty(exports, "isTestFile", { enumerable: true, get: function () { return test_files_1.isTestFile; } });
120
143
  const test_files_2 = require("./test-files");
144
+ // @implements A-SPEC-574.3 — a plain import now: the cycle that forced a lazy require is gone.
145
+ const scan_cache_1 = require("./scan-cache");
121
146
  const anchor_ids_1 = require("../rtm/anchor-ids");
122
147
  // .ts/.mts/.cts can contain TS type-assertion syntax (`<T>x`, arrow-generics)
123
148
  // that the 'tsx' grammar misreads as JSX, so they must stay on 'typescript'.
@@ -208,7 +233,7 @@ class CpgScanner {
208
233
  // C++ grammar parses plain C headers acceptably for our lexical
209
234
  // symbol walk, and disambiguating a bare `.h` between C and C++
210
235
  // is out of scope for this slice.
211
- const lang = /\.py$/.test(e.name) ? 'python' : (/\.cs$/.test(e.name) ? 'csharp' : (/\.java$/.test(e.name) ? 'java' : (/\.go$/.test(e.name) ? 'go' : (/\.rs$/.test(e.name) ? 'rust' : (/\.(cpp|cc|cxx|hpp|hh|h)$/.test(e.name) ? 'cpp' : (TSX_GRAMMAR_RE.test(e.name) ? 'tsx' : 'typescript'))))));
236
+ const lang = langForPath(e.name);
212
237
  // @implements A-SPEC-503.1 — comma-listed anchors: the old single-capture regexes
213
238
  // dropped every id after the first (C12), and a wrong-kind id in second position was
214
239
  // invisible to the warning. Both consumers now ride the shared list parser.
@@ -221,8 +246,7 @@ class CpgScanner {
221
246
  const dataflow = () => (this.opts.dataflow ? this.parser.extractDataFlow(code, lang) : undefined);
222
247
  // N7 incremental: on a content-hash hit, reuse cached symbols and skip the parse.
223
248
  if (this.cache) {
224
- const { ScanFileCache } = require('./scan-cache');
225
- const h = ScanFileCache.hashOf(code);
249
+ const h = scan_cache_1.ScanFileCache.hashOf(code);
226
250
  const hit = this.cache.get(sourcePath, h);
227
251
  // @implements A-SPEC-140.1
228
252
  // A hit written by a scan that did NOT extract data-flow cannot satisfy one that does:
@@ -0,0 +1,87 @@
1
+ export type EdgeKind = 'type-erasable' | 'lazy-require' | 'eager-value';
2
+ export interface CycleEdge {
3
+ from: string;
4
+ to: string;
5
+ kind: EdgeKind;
6
+ }
7
+ export interface Cycle {
8
+ /** The files in the cycle, sorted, so two runs report it the same way. */
9
+ files: string[];
10
+ edges: CycleEdge[];
11
+ /** True unless EVERY edge holding the cycle together is erased at compile time. */
12
+ runtime: boolean;
13
+ }
14
+ /**
15
+ * What survives compilation for one import edge.
16
+ *
17
+ * Deliberately conservative: a shape this cannot read is `eager-value`, never `type-erasable`.
18
+ * Reporting "this disappears at compile time" about something we did not understand is the one
19
+ * error that silently deletes a real cycle from the count.
20
+ */
21
+ export declare function classifyImportEdge(source: string, specifier: string): EdgeKind;
22
+ /**
23
+ * Strongly connected components of size > 1 — Tarjan, iterative order-independent output.
24
+ *
25
+ * A self-edge is not a cycle: a file importing itself is a parse artefact, not a design defect.
26
+ */
27
+ export declare function detectCycles(edges: readonly CycleEdge[]): {
28
+ cycles: Cycle[];
29
+ runtimeCycles: number;
30
+ };
31
+ /**
32
+ * @implements A-SPEC-574.2
33
+ * The wording that ships with the advisory. Fixed here, not composed at the call site, so the one
34
+ * thing this surface must never claim cannot drift: it reports cycles that ALREADY exist among the
35
+ * declared files. What a design is about to create is not visible until the code is written.
36
+ */
37
+ export declare const CYCLE_ADVISORY_NOTE: string;
38
+ /**
39
+ * @implements A-SPEC-574.2
40
+ * Classify the edge `fromFile -> toFile` by finding the import statement that actually resolves to
41
+ * that target.
42
+ *
43
+ * Picking the statement by the target's BASENAME would classify the wrong import whenever two files
44
+ * share a name in different directories — which is ordinary in any tree with `util.ts` or
45
+ * `index.ts`. The specifier is resolved instead, the way the module system does it.
46
+ *
47
+ * A target no specifier in this source resolves to is `eager-value`: not finding it is not evidence
48
+ * that it disappears at compile time.
49
+ */
50
+ export declare function classifyEdgeByTarget(source: string, fromFile: string, toFile: string): EdgeKind;
51
+ export interface CycleFinding extends Cycle {
52
+ /** The declared files that put this cycle in front of the author. */
53
+ declared: string[];
54
+ }
55
+ /**
56
+ * @implements A-SPEC-574.2
57
+ * The cycles the DECLARED files are already in — read before the Files-to-Touch are frozen.
58
+ *
59
+ * This is not a prediction and must never be presented as one. Import edges come from scanning real
60
+ * source, so the cycle a design would CREATE does not exist to be seen at approval time; what can
61
+ * be seen is that a file the author is about to edit already sits in one. Selling the first as the
62
+ * second would be worse than silence.
63
+ *
64
+ * Calls `detectCycles` rather than computing its own: the advisory and the later verdict have to be
65
+ * the same function, or "what we warned" and "what we judge" drift apart (A-SPEC-572.1).
66
+ */
67
+ export declare function cycleAdvisory(fttFiles: readonly string[], edges: readonly CycleEdge[]): CycleFinding[];
68
+ export interface CycleViolation {
69
+ key: string;
70
+ files: string[];
71
+ detail: string;
72
+ }
73
+ /**
74
+ * @implements A-SPEC-574.4
75
+ * The runtime cycles nobody allowed — a RATCHET, not an absolute zero.
76
+ *
77
+ * Absolute zero would keep every project with a legacy cycle from adopting the harness at all, and
78
+ * a rule that blocks adoption is a barrier rather than a rule. The escape is a NAME in the allow
79
+ * list, never a threshold: naming a cycle is a decision someone signed, while a threshold is a
80
+ * number anyone can move.
81
+ *
82
+ * Type-erasable-only cycles are not counted: TypeScript deletes those imports, so refusing them
83
+ * would refuse something that does not exist at runtime.
84
+ */
85
+ export declare function cycleRatchetViolations(current: readonly Cycle[], allowed: readonly string[]): CycleViolation[];
86
+ /** Order-independent identity for a cycle, so the allow list cannot be defeated by a reordering. */
87
+ export declare function cycleKey(files: readonly string[]): string;
@@ -0,0 +1,251 @@
1
+ "use strict";
2
+ // @implements A-SPEC-574.1
3
+ // The spec graph has been cycle-free by constitution (ART-2) since early on; the CODE graph never
4
+ // was — the same repository enforcing acyclicity on one graph and not the other. Census 2026-09-08
5
+ // on src/holmes: three cycles, whose edges are three different things. A rule that cannot tell them
6
+ // apart is wrong on its first day: TypeScript ERASES a type-only import, so a "cycle" held up only
7
+ // by those does not exist at runtime.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.CYCLE_ADVISORY_NOTE = void 0;
10
+ exports.classifyImportEdge = classifyImportEdge;
11
+ exports.detectCycles = detectCycles;
12
+ exports.classifyEdgeByTarget = classifyEdgeByTarget;
13
+ exports.cycleAdvisory = cycleAdvisory;
14
+ exports.cycleRatchetViolations = cycleRatchetViolations;
15
+ exports.cycleKey = cycleKey;
16
+ /**
17
+ * What survives compilation for one import edge.
18
+ *
19
+ * Deliberately conservative: a shape this cannot read is `eager-value`, never `type-erasable`.
20
+ * Reporting "this disappears at compile time" about something we did not understand is the one
21
+ * error that silently deletes a real cycle from the count.
22
+ */
23
+ function classifyImportEdge(source, specifier) {
24
+ const quoted = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ // A require() anywhere but the top level is the deliberate cycle-breaking idiom. Its PRESENCE is
26
+ // evidence the cycle was real enough that someone paid to work around it.
27
+ if (new RegExp(`require\\(\\s*['"]${quoted}['"]\\s*\\)`).test(source))
28
+ return 'lazy-require';
29
+ const decl = new RegExp(`^\\s*import\\s+(type\\s+)?([^;]*?)\\s+from\\s+['"]${quoted}['"]`, 'm').exec(source);
30
+ if (decl === null)
31
+ return 'eager-value';
32
+ if (decl[1] !== undefined)
33
+ return 'type-erasable'; // explicit `import type`
34
+ const clause = decl[2].trim();
35
+ // Namespace and default imports carry no per-binding information here — not readable, so eager.
36
+ const named = /^\{([^}]*)\}$/.exec(clause);
37
+ if (named === null)
38
+ return 'eager-value';
39
+ const bindings = named[1].split(',')
40
+ .map((b) => b.split(/\s+as\s+/).pop().trim())
41
+ .filter((b) => b !== '');
42
+ if (bindings.length === 0)
43
+ return 'eager-value';
44
+ // A binding used ONLY in type position is elided by TypeScript. "Type position" is approximated
45
+ // by the shapes a tree-sitter-free reader can see: after `:`, after `<`, in `extends`/`implements`,
46
+ // or as a type alias right-hand side. Anything else — a call, a `new`, a value reference — makes
47
+ // the whole edge eager, because one surviving binding keeps the import statement alive.
48
+ // Comments are stripped first. Measured on the real corpus: `scan-cache.ts` uses `ScannedFile`
49
+ // only in type positions, but a prose comment saying "the ScannedFile schema" made the whole edge
50
+ // read as a value use — the classifier was scoring English.
51
+ const body = stripComments(source.slice(decl.index + decl[0].length));
52
+ return bindings.every((b) => usedOnlyAsType(body, b)) ? 'type-erasable' : 'eager-value';
53
+ }
54
+ /** Line and block comments removed, so prose cannot look like code. String bodies are left alone. */
55
+ function stripComments(source) {
56
+ return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/^[^\n]*?\/\/[^\n]*$/gm, (line) => {
57
+ const at = line.indexOf('//');
58
+ return at < 0 ? line : line.slice(0, at);
59
+ });
60
+ }
61
+ function usedOnlyAsType(body, binding) {
62
+ const name = binding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
63
+ const uses = [...body.matchAll(new RegExp(`\\b${name}\\b`, 'g'))];
64
+ if (uses.length === 0)
65
+ return true; // imported and never used: nothing survives
66
+ return uses.every((m) => {
67
+ const before = body.slice(Math.max(0, m.index - 40), m.index);
68
+ const after = body.slice(m.index + binding.length, m.index + binding.length + 2);
69
+ if (/[:<|&,(]\s*$/.test(before) && !/\(\s*$/.test(before))
70
+ return true; // annotation, generic arg, union
71
+ if (/\b(extends|implements|satisfies)\s+$/.test(before))
72
+ return true;
73
+ if (/\btype\s+\w+\s*=\s*$/.test(before))
74
+ return true;
75
+ if (/^\s*[<[]/.test(after) && /[:<|&]\s*$/.test(before))
76
+ return true;
77
+ return false;
78
+ });
79
+ }
80
+ /**
81
+ * Strongly connected components of size > 1 — Tarjan, iterative order-independent output.
82
+ *
83
+ * A self-edge is not a cycle: a file importing itself is a parse artefact, not a design defect.
84
+ */
85
+ function detectCycles(edges) {
86
+ const out = new Map();
87
+ const nodes = new Set();
88
+ for (const e of edges) {
89
+ if (e.from === e.to)
90
+ continue;
91
+ nodes.add(e.from);
92
+ nodes.add(e.to);
93
+ (out.get(e.from) ?? out.set(e.from, []).get(e.from)).push(e.to);
94
+ }
95
+ // Sorted adjacency and sorted roots: the same edge set in any input order yields the same result.
96
+ for (const [, list] of out)
97
+ list.sort();
98
+ let counter = 0;
99
+ const index = new Map();
100
+ const low = new Map();
101
+ const onStack = new Set();
102
+ const stack = [];
103
+ const components = [];
104
+ const strongconnect = (v) => {
105
+ index.set(v, counter);
106
+ low.set(v, counter);
107
+ counter++;
108
+ stack.push(v);
109
+ onStack.add(v);
110
+ for (const w of out.get(v) ?? []) {
111
+ if (!index.has(w)) {
112
+ strongconnect(w);
113
+ low.set(v, Math.min(low.get(v), low.get(w)));
114
+ }
115
+ else if (onStack.has(w))
116
+ low.set(v, Math.min(low.get(v), index.get(w)));
117
+ }
118
+ if (low.get(v) === index.get(v)) {
119
+ const comp = [];
120
+ let w;
121
+ do {
122
+ w = stack.pop();
123
+ onStack.delete(w);
124
+ comp.push(w);
125
+ } while (w !== v);
126
+ if (comp.length > 1)
127
+ components.push(comp.sort());
128
+ }
129
+ };
130
+ for (const v of [...nodes].sort())
131
+ if (!index.has(v))
132
+ strongconnect(v);
133
+ const cycles = components
134
+ .map((files) => {
135
+ const members = new Set(files);
136
+ // Sorted, not input order: `files` alone being sorted still let two runs of the same edge set
137
+ // differ, which the determinism case caught.
138
+ // Deduped: the scanner emits one edge per occurrence, so three `require('./y')` calls in one
139
+ // file said the same thing three times. Sorted, because `files` alone being sorted still let
140
+ // two runs of the same edge set differ.
141
+ const seen = new Set();
142
+ const held = edges
143
+ .filter((e) => e.from !== e.to && members.has(e.from) && members.has(e.to))
144
+ .filter((e) => {
145
+ const key = `${e.from}\u0000${e.to}\u0000${e.kind}`;
146
+ if (seen.has(key))
147
+ return false;
148
+ seen.add(key);
149
+ return true;
150
+ })
151
+ .sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
152
+ return {
153
+ files,
154
+ edges: held,
155
+ // One surviving edge is enough to keep the cycle at runtime; all-erasable means it is gone.
156
+ runtime: held.some((e) => e.kind !== 'type-erasable'),
157
+ };
158
+ })
159
+ .sort((a, b) => a.files[0].localeCompare(b.files[0]));
160
+ return { cycles, runtimeCycles: cycles.filter((c) => c.runtime).length };
161
+ }
162
+ /**
163
+ * @implements A-SPEC-574.2
164
+ * The wording that ships with the advisory. Fixed here, not composed at the call site, so the one
165
+ * thing this surface must never claim cannot drift: it reports cycles that ALREADY exist among the
166
+ * declared files. What a design is about to create is not visible until the code is written.
167
+ */
168
+ exports.CYCLE_ADVISORY_NOTE = '선언한 파일이 이미 순환 안에 있습니다. 이것은 예측이 아닙니다 — '
169
+ + 'import 간선은 실제 소스에서 나오므로, 아직 쓰이지 않은 코드가 만들 순환은 여기서 보이지 않습니다.';
170
+ /**
171
+ * @implements A-SPEC-574.2
172
+ * Classify the edge `fromFile -> toFile` by finding the import statement that actually resolves to
173
+ * that target.
174
+ *
175
+ * Picking the statement by the target's BASENAME would classify the wrong import whenever two files
176
+ * share a name in different directories — which is ordinary in any tree with `util.ts` or
177
+ * `index.ts`. The specifier is resolved instead, the way the module system does it.
178
+ *
179
+ * A target no specifier in this source resolves to is `eager-value`: not finding it is not evidence
180
+ * that it disappears at compile time.
181
+ */
182
+ function classifyEdgeByTarget(source, fromFile, toFile) {
183
+ const dir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : '';
184
+ const target = toFile.replace(/(\/index)?\.[cm]?[jt]sx?$/, '');
185
+ for (const m of source.matchAll(/from\s+['"](\.[^'"]*)['"]|require\(\s*['"](\.[^'"]*)['"]\s*\)/g)) {
186
+ const spec = m[1] ?? m[2];
187
+ if (spec === undefined)
188
+ continue;
189
+ if (normalizeRelative(dir, spec) === target)
190
+ return classifyImportEdge(source, spec);
191
+ }
192
+ return 'eager-value';
193
+ }
194
+ /** POSIX-style join+normalize, without pulling in `path` — this module stays pure and portable. */
195
+ function normalizeRelative(dir, spec) {
196
+ const parts = (dir === '' ? [] : dir.split('/')).concat(spec.split('/'));
197
+ const out = [];
198
+ for (const p of parts) {
199
+ if (p === '' || p === '.')
200
+ continue;
201
+ if (p === '..') {
202
+ out.pop();
203
+ continue;
204
+ }
205
+ out.push(p);
206
+ }
207
+ return out.join('/').replace(/(\/index)?\.[cm]?[jt]sx?$/, '');
208
+ }
209
+ /**
210
+ * @implements A-SPEC-574.2
211
+ * The cycles the DECLARED files are already in — read before the Files-to-Touch are frozen.
212
+ *
213
+ * This is not a prediction and must never be presented as one. Import edges come from scanning real
214
+ * source, so the cycle a design would CREATE does not exist to be seen at approval time; what can
215
+ * be seen is that a file the author is about to edit already sits in one. Selling the first as the
216
+ * second would be worse than silence.
217
+ *
218
+ * Calls `detectCycles` rather than computing its own: the advisory and the later verdict have to be
219
+ * the same function, or "what we warned" and "what we judge" drift apart (A-SPEC-572.1).
220
+ */
221
+ function cycleAdvisory(fttFiles, edges) {
222
+ const declaredSet = new Set(fttFiles);
223
+ if (declaredSet.size === 0)
224
+ return [];
225
+ return detectCycles(edges).cycles
226
+ .map((c) => ({ ...c, declared: c.files.filter((f) => declaredSet.has(f)) }))
227
+ .filter((c) => c.declared.length > 0);
228
+ }
229
+ /**
230
+ * @implements A-SPEC-574.4
231
+ * The runtime cycles nobody allowed — a RATCHET, not an absolute zero.
232
+ *
233
+ * Absolute zero would keep every project with a legacy cycle from adopting the harness at all, and
234
+ * a rule that blocks adoption is a barrier rather than a rule. The escape is a NAME in the allow
235
+ * list, never a threshold: naming a cycle is a decision someone signed, while a threshold is a
236
+ * number anyone can move.
237
+ *
238
+ * Type-erasable-only cycles are not counted: TypeScript deletes those imports, so refusing them
239
+ * would refuse something that does not exist at runtime.
240
+ */
241
+ function cycleRatchetViolations(current, allowed) {
242
+ const permitted = new Set(allowed);
243
+ return current
244
+ .filter((c) => c.runtime)
245
+ .map((c) => ({ key: cycleKey(c.files), files: c.files, detail: `import cycle: ${c.files.join(' -> ')} -> ${c.files[0]}` }))
246
+ .filter((v) => !permitted.has(v.key));
247
+ }
248
+ /** Order-independent identity for a cycle, so the allow list cannot be defeated by a reordering. */
249
+ function cycleKey(files) {
250
+ return [...files].sort().join('|');
251
+ }
@@ -0,0 +1,65 @@
1
+ import { type Cycle } from './cycle-detect';
2
+ /**
3
+ * How many cycles one record lists before it starts counting instead.
4
+ *
5
+ * A record must not grow with the tree: a repository with a thousand cycles would otherwise write a
6
+ * thousand-entry line every turn, and the ledger this exists to make readable would be the thing
7
+ * that makes it unreadable. What is dropped is COUNTED, never silently cut.
8
+ */
9
+ export declare const CYCLE_LIST_CAP = 50;
10
+ /**
11
+ * One turn's observation — paths, integers and enums only.
12
+ *
13
+ * ADR-012's redaction rule is what lets this file be git-tracked at all: no prose, no command
14
+ * strings, no file content. `files` are the scanner's project-relative paths, which the impact and
15
+ * density ledgers already carry.
16
+ */
17
+ export interface CycleObservationRecord {
18
+ ts: string;
19
+ mode: 'strict' | 'track' | 'off';
20
+ cycles: Array<{
21
+ key: string;
22
+ files: string[];
23
+ runtime: boolean;
24
+ edges: number;
25
+ }>;
26
+ /** Cycles beyond CYCLE_LIST_CAP: counted, so a truncation is never mistaken for a clean tree. */
27
+ cyclesOmitted: number;
28
+ /** Keys of the runtime cycles nobody allowed — the ratchet's verdict at this moment. */
29
+ violations: string[];
30
+ allowed: number;
31
+ scope: {
32
+ judged: string[];
33
+ unavailable: string[];
34
+ };
35
+ replica?: string;
36
+ }
37
+ /** The cycle evidence the Stop hook computes, in the shape it already holds it. */
38
+ export interface CycleEvidence {
39
+ current: Cycle[];
40
+ allowed: string[];
41
+ mode: 'strict' | 'track' | 'off';
42
+ scope: {
43
+ judged: string[];
44
+ unavailable: string[];
45
+ };
46
+ }
47
+ /**
48
+ * Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
49
+ * disagree about what "now" was.
50
+ *
51
+ * A CLEAN run produces a record too. That is the whole design: a false-positive RATE is violations
52
+ * over chances, and a ledger that only speaks when something is wrong records the numerator and
53
+ * throws the denominator away.
54
+ */
55
+ export declare function buildCycleObservation(ev: CycleEvidence, ts: string): CycleObservationRecord;
56
+ /**
57
+ * Append one record. Never throws, and never creates `.ax` where governance was not opted into
58
+ * (A-SPEC-191 §25 — the same refusal the approval queue makes).
59
+ *
60
+ * A failure returns `false` and changes nothing else: this is an OBSERVATION, and an observation
61
+ * that could alter a verdict would be a gate wearing a different name.
62
+ */
63
+ export declare function appendCycleObservation(root: string, rec: CycleObservationRecord): boolean;
64
+ /** Every replica's records, merged. A corrupt line is skipped, never fatal. */
65
+ export declare function readCycleObservations(root: string): CycleObservationRecord[];