@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.
- package/CHANGELOG.md +149 -0
- package/README.md +3 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +22 -0
- package/dist/holmes/cli/agents.js +76 -1
- package/dist/holmes/cli/approve.js +6 -1
- package/dist/holmes/cli/doctor.d.ts +36 -1
- package/dist/holmes/cli/doctor.js +182 -35
- package/dist/holmes/cli/index.js +7 -1
- package/dist/holmes/cli/init.js +12 -0
- package/dist/holmes/cli/native-deps.d.ts +65 -0
- package/dist/holmes/cli/native-deps.js +131 -0
- package/dist/holmes/cli/release-docs.d.ts +27 -0
- package/dist/holmes/cli/release-docs.js +68 -0
- package/dist/holmes/cpg/arch-observe.d.ts +15 -0
- package/dist/holmes/cpg/arch-observe.js +19 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
- package/dist/holmes/cpg/cpg-scanner.js +27 -3
- package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
- package/dist/holmes/cpg/cycle-detect.js +251 -0
- package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
- package/dist/holmes/cpg/cycle-observation.js +146 -0
- package/dist/holmes/cpg/scan-cache.d.ts +1 -1
- package/dist/holmes/cpg/scanned-file.d.ts +36 -0
- package/dist/holmes/cpg/scanned-file.js +2 -0
- package/dist/holmes/governance/approval-queue.d.ts +23 -4
- package/dist/holmes/governance/approval-queue.js +44 -6
- package/dist/holmes/governance/constitution.d.ts +20 -0
- package/dist/holmes/governance/constitution.js +17 -0
- package/dist/holmes/governance/ledger-store.d.ts +9 -0
- package/dist/holmes/governance/ledger-store.js +47 -0
- package/dist/holmes/governance/provenance-chain.d.ts +16 -1
- package/dist/holmes/governance/provenance-chain.js +5 -3
- package/dist/holmes/hooks/pre-tool-use.js +3 -1
- package/dist/holmes/hooks/stop.d.ts +29 -0
- package/dist/holmes/hooks/stop.js +119 -3
- package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
- package/dist/holmes/mcp/defuse-bound.js +8 -0
- package/dist/holmes/mcp/handlers.d.ts +7 -0
- package/dist/holmes/mcp/handlers.js +132 -6
- package/dist/holmes/mcp/history-admission.d.ts +15 -0
- package/dist/holmes/mcp/history-admission.js +37 -0
- package/dist/holmes/mcp/maintenance-analyze.d.ts +45 -0
- package/dist/holmes/mcp/maintenance-analyze.js +117 -9
- package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
- package/dist/holmes/mcp/maintenance-evidence.js +71 -4
- package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
- package/dist/holmes/project/install-scripts-policy.js +131 -0
- package/dist/holmes/project/npx-bin.d.ts +6 -0
- package/dist/holmes/project/npx-bin.js +10 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
- package/dist/holmes/review/evaluation-metrics.js +18 -1
- package/dist/holmes/review/failed-test-names.d.ts +19 -0
- package/dist/holmes/review/failed-test-names.js +43 -0
- package/dist/holmes/review/paired-power.d.ts +14 -0
- package/dist/holmes/review/paired-power.js +57 -0
- package/dist/holmes/review/replay-corpus.d.ts +11 -0
- package/dist/holmes/review/replay-corpus.js +34 -0
- package/dist/holmes/review/run-replay.d.ts +23 -0
- package/dist/holmes/review/run-replay.js +90 -4
- package/dist/holmes/review/symbol-truth.d.ts +14 -0
- package/dist/holmes/review/symbol-truth.js +23 -0
- package/dist/holmes/review/test-runner.d.ts +27 -0
- package/dist/holmes/review/test-runner.js +59 -3
- package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
- package/dist/holmes/rtm/defuse-symbols.js +91 -0
- package/dist/holmes/rtm/incremental.js +5 -0
- package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
- package/dist/holmes/rtm/rtm-builder.js +32 -5
- package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/spec/legacy-fields.d.ts +2 -0
- package/dist/holmes/spec/legacy-fields.js +9 -0
- package/dist/holmes/spec/legacy-format.d.ts +1 -1
- package/dist/holmes/spec/legacy-format.js +4 -1
- package/dist/holmes/spec/spec-parser.js +5 -3
- package/docs/install-guide.md +54 -5
- package/package.json +4 -1
- package/playbooks/author-slice/PLAYBOOK.md +14 -0
- package/playbooks/publish/PLAYBOOK.md +32 -0
- 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
|
|
2
|
-
|
|
3
|
-
|
|
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 =
|
|
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
|
|
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[];
|