@holmes-lab/holmes-kit 0.18.0 → 0.19.0
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 +69 -0
- package/README.md +3 -0
- package/dist/.build-id +1 -1
- 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/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/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 +14 -0
- package/dist/holmes/hooks/stop.js +73 -0
- 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 +5 -0
- package/dist/holmes/mcp/handlers.js +103 -4
- 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 +8 -0
- package/dist/holmes/mcp/maintenance-analyze.js +44 -8
- package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
- package/dist/holmes/review/evaluation-metrics.js +18 -1
- 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.js +60 -4
- package/dist/holmes/review/symbol-truth.d.ts +14 -0
- package/dist/holmes/review/symbol-truth.js +23 -0
- 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/package.json +1 -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,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,36 @@
|
|
|
1
|
+
import { 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
|
+
}
|
|
@@ -44,6 +44,26 @@ export interface ConstitutionContext {
|
|
|
44
44
|
severity?: string;
|
|
45
45
|
summary?: string;
|
|
46
46
|
}[];
|
|
47
|
+
/**
|
|
48
|
+
* @implements A-SPEC-574.4
|
|
49
|
+
* Import cycles in the CODE graph, supplied by the hook — the constitution stays pure and does no
|
|
50
|
+
* I/O, exactly as ART-4's evidence and ART-6's findings are supplied.
|
|
51
|
+
*
|
|
52
|
+
* Absent means SILENT: a project whose hook could not scan is not a project without cycles, and
|
|
53
|
+
* inventing a clean verdict from missing data is the failure mode this repository names most
|
|
54
|
+
* often. `scope.unavailable` carries the languages whose imports could not be resolved, so the
|
|
55
|
+
* report can say what it did NOT judge instead of implying zero.
|
|
56
|
+
*/
|
|
57
|
+
cycles?: {
|
|
58
|
+
current: import('../cpg/cycle-detect').Cycle[];
|
|
59
|
+
/** Cycle keys a C-SPEC named as accepted. The escape is a name, never a threshold. */
|
|
60
|
+
allowed: string[];
|
|
61
|
+
mode: 'strict' | 'track' | 'off';
|
|
62
|
+
scope: {
|
|
63
|
+
judged: string[];
|
|
64
|
+
unavailable: string[];
|
|
65
|
+
};
|
|
66
|
+
};
|
|
47
67
|
/** A-SPEC id -> count of real test cases in anchored test files (H1 syntactic lower bound). */
|
|
48
68
|
testCasesByAspec?: Record<string, number>;
|
|
49
69
|
/**
|
|
@@ -6,6 +6,8 @@ exports.verifyConstitution = verifyConstitution;
|
|
|
6
6
|
const basis_1 = require("../mcp/basis");
|
|
7
7
|
const spec_types_1 = require("../spec/spec-types");
|
|
8
8
|
const rtm_check_1 = require("../rtm/rtm-check");
|
|
9
|
+
// @implements A-SPEC-574.4 — the same detector the design-time advisory calls.
|
|
10
|
+
const cycle_detect_1 = require("../cpg/cycle-detect");
|
|
9
11
|
const validator_1 = require("../spec/validator");
|
|
10
12
|
/**
|
|
11
13
|
* @implements A-SPEC-534.2
|
|
@@ -54,6 +56,21 @@ function verifyConstitution(ctx) {
|
|
|
54
56
|
for (const issue of (0, rtm_check_1.rtmCheck)(governed)) {
|
|
55
57
|
v.push({ article: 'ART-2', detail: `RTM ${issue.kind} [${issue.id}]: ${issue.detail}` });
|
|
56
58
|
}
|
|
59
|
+
// @implements A-SPEC-574.4
|
|
60
|
+
// ART-2's sibling on the OTHER graph. The spec graph has been required to be acyclic since early
|
|
61
|
+
// on; the code graph never was, in the same repository. Shipped in `track` first — the posture
|
|
62
|
+
// that records without blocking — because a gate whose false-positive rate nobody has measured
|
|
63
|
+
// gets promoted by evidence, not by enthusiasm (the impactAdvisory / anchorDensity lifecycle).
|
|
64
|
+
// STRICT ONLY here, exactly like ART-8: `track` must record without blocking, and a violation
|
|
65
|
+
// emitted from this function IS a block. The hook computes the track list separately.
|
|
66
|
+
if (ctx.cycles !== undefined && ctx.cycles.mode === 'strict') {
|
|
67
|
+
const scope = ctx.cycles.scope.unavailable.length > 0
|
|
68
|
+
? ` (judged: ${ctx.cycles.scope.judged.join(', ') || 'none'}; NOT judged: ${ctx.cycles.scope.unavailable.join(', ')})`
|
|
69
|
+
: '';
|
|
70
|
+
for (const found of (0, cycle_detect_1.cycleRatchetViolations)(ctx.cycles.current, ctx.cycles.allowed)) {
|
|
71
|
+
v.push({ article: 'ART-2', detail: `code ${found.detail}${scope}` });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
57
74
|
// ART-3: spec validity (type rules + 4-quadrant GWT via validateSpec)
|
|
58
75
|
for (const s of governed) {
|
|
59
76
|
const r = (0, validator_1.validateSpec)(s, resolve);
|
|
@@ -112,3 +112,12 @@ export declare class MemoryLedgerStore implements LedgerStore {
|
|
|
112
112
|
append(body: Omit<ProvenanceEvent, 'prevHash' | 'hash' | 'seq'>): ProvenanceEvent;
|
|
113
113
|
isNonceConsumed(nonce: string): boolean;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The cross-replica nonce check, bound to one ledger file, ready to hand to
|
|
117
|
+
* `consumeNonceExclusively`.
|
|
118
|
+
*
|
|
119
|
+
* Lives HERE, on the store side, because that is the direction the dependency should point: the
|
|
120
|
+
* chain layer asks a question the caller answers, instead of reaching into the store through a lazy
|
|
121
|
+
* `require()` to dodge an import cycle. One helper rather than three copies at the call sites.
|
|
122
|
+
*/
|
|
123
|
+
export declare function nonceConsumedIn(ledgerFile: string): (nonce: string) => boolean;
|
|
@@ -1,9 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.MemoryLedgerStore = exports.FileLedgerStore = void 0;
|
|
37
|
+
exports.nonceConsumedIn = nonceConsumedIn;
|
|
4
38
|
// @implements A-SPEC-150
|
|
5
39
|
const provenance_chain_1 = require("./provenance-chain");
|
|
6
40
|
const provenance_ledger_1 = require("./provenance-ledger");
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
7
42
|
/**
|
|
8
43
|
* Local adapter — DELEGATES to `ProvenanceLedger` rather than re-implementing it.
|
|
9
44
|
*
|
|
@@ -104,3 +139,15 @@ class MemoryLedgerStore {
|
|
|
104
139
|
}
|
|
105
140
|
}
|
|
106
141
|
exports.MemoryLedgerStore = MemoryLedgerStore;
|
|
142
|
+
// @implements A-SPEC-574.3
|
|
143
|
+
/**
|
|
144
|
+
* The cross-replica nonce check, bound to one ledger file, ready to hand to
|
|
145
|
+
* `consumeNonceExclusively`.
|
|
146
|
+
*
|
|
147
|
+
* Lives HERE, on the store side, because that is the direction the dependency should point: the
|
|
148
|
+
* chain layer asks a question the caller answers, instead of reaching into the store through a lazy
|
|
149
|
+
* `require()` to dodge an import cycle. One helper rather than three copies at the call sites.
|
|
150
|
+
*/
|
|
151
|
+
function nonceConsumedIn(ledgerFile) {
|
|
152
|
+
return (nonce) => new FileLedgerStore(path.dirname(ledgerFile)).isNonceConsumed(nonce);
|
|
153
|
+
}
|
|
@@ -177,5 +177,20 @@ export declare const PROVENANCE_FILE: string;
|
|
|
177
177
|
* decision, not this function's: an authorization gate must deny, while a record-keeping append must
|
|
178
178
|
* not flip a decision that was already made.
|
|
179
179
|
*/
|
|
180
|
-
export declare function consumeNonceExclusively(nonce: string, ledgerFile: string, body: Omit<ProvenanceBody, 'seq'>, opts
|
|
180
|
+
export declare function consumeNonceExclusively(nonce: string, ledgerFile: string, body: Omit<ProvenanceBody, 'seq'>, opts: LockOptions & {
|
|
181
|
+
/**
|
|
182
|
+
* @implements A-SPEC-574.3
|
|
183
|
+
* Whether this nonce was already consumed, ACROSS every replica chain (A-SPEC-148's rule).
|
|
184
|
+
*
|
|
185
|
+
* Injected, because the chain layer has no business knowing about the store layer — it used to
|
|
186
|
+
* reach for `FileLedgerStore` through a lazy `require()`, and that workaround existed only to
|
|
187
|
+
* dodge the import cycle it created. The caller owns the store; the caller passes the question.
|
|
188
|
+
*
|
|
189
|
+
* REQUIRED, and that is the point. The first cut made it optional with a `?? false` default,
|
|
190
|
+
* and `ledger-race` went red immediately: the second consume of the same nonce SUCCEEDED. A
|
|
191
|
+
* default of "nothing is spent" turns REQ-141's whole reason for existing into an opt-in. A
|
|
192
|
+
* missing security check has to be a compile error, not a quiet pass.
|
|
193
|
+
*/
|
|
194
|
+
isNonceConsumed: (nonce: string) => boolean;
|
|
195
|
+
}): boolean;
|
|
181
196
|
export {};
|
|
@@ -427,8 +427,10 @@ function consumeNonceExclusively(nonce, ledgerFile, body, opts) {
|
|
|
427
427
|
// across machines there is no shared filesystem to lock, so two machines racing the same nonce
|
|
428
428
|
// can both pass and the duplicate is DETECTED afterwards in the merged ledger rather than
|
|
429
429
|
// prevented. Preventing it needs a shared authority, which is out of this slice's scope.
|
|
430
|
-
|
|
431
|
-
|
|
430
|
+
// @implements A-SPEC-574.3 — the check comes from the caller. Absent, nothing is known to be
|
|
431
|
+
// spent: a caller that does not supply the store is asking for an append, not for exclusion,
|
|
432
|
+
// and inventing a false "already consumed" here would silently refuse a legitimate action.
|
|
433
|
+
const spent = opts.isNonceConsumed(nonce);
|
|
432
434
|
if (spent)
|
|
433
435
|
return false;
|
|
434
436
|
chain.appendInsideLock(body);
|
|
@@ -436,5 +438,5 @@ function consumeNonceExclusively(nonce, ledgerFile, body, opts) {
|
|
|
436
438
|
// Default the stale-break recorder to the chain's own: breaking a lock silently is forbidden
|
|
437
439
|
// here for exactly the same reason it is in `append()`, and leaving it undefined made this
|
|
438
440
|
// path — the SECURITY path — the one place a lock could be stolen without a trace.
|
|
439
|
-
}, { ...opts, onStaleBreak: opts
|
|
441
|
+
}, { ...opts, onStaleBreak: opts.onStaleBreak ?? chain.staleBreakRecorder() });
|
|
440
442
|
}
|
|
@@ -72,6 +72,8 @@ const governance_history_1 = require("../guardrail/governance-history");
|
|
|
72
72
|
const rtm_check_1 = require("../rtm/rtm-check");
|
|
73
73
|
const ledger_store_1 = require("../governance/ledger-store");
|
|
74
74
|
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
75
|
+
// @implements A-SPEC-574.3 — the caller owns the store and hands the chain the question.
|
|
76
|
+
const ledger_store_2 = require("../governance/ledger-store");
|
|
75
77
|
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
76
78
|
const approval_queue_1 = require("../governance/approval-queue");
|
|
77
79
|
const approval_grants_1 = require("../governance/approval-grants");
|
|
@@ -491,7 +493,7 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
491
493
|
ts: nowTs, actor: acting.actor, kind: 'nonce-consumed',
|
|
492
494
|
summary: `consumed single-use approval for: ${(0, provenance_chain_1.redactTarget)('command', command)}`.slice(0, 200),
|
|
493
495
|
inputs: [(0, provenance_chain_1.nonceFingerprint)(acting.nonce)], rationale: acting.rationale, authorization: (0, provenance_chain_1.authorizationRef)(acting.actor, acting.token),
|
|
494
|
-
});
|
|
496
|
+
}, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
|
|
495
497
|
}
|
|
496
498
|
catch (err) {
|
|
497
499
|
// FAIL CLOSED. A gate that cannot prove exclusivity must not grant — degrading to an
|
|
@@ -95,6 +95,20 @@ export interface StopEvidence {
|
|
|
95
95
|
ts: string;
|
|
96
96
|
}>>;
|
|
97
97
|
redFirstMode?: 'strict' | 'track' | 'off';
|
|
98
|
+
/**
|
|
99
|
+
* @implements A-SPEC-574.4
|
|
100
|
+
* Code-graph cycles, scanned by this hook. Absent = not scanned, which the constitution treats as
|
|
101
|
+
* silence rather than as "none" — a project whose scan failed has not been cleared.
|
|
102
|
+
*/
|
|
103
|
+
cycles?: {
|
|
104
|
+
current: import('../cpg/cycle-detect').Cycle[];
|
|
105
|
+
allowed: string[];
|
|
106
|
+
mode: 'strict' | 'track' | 'off';
|
|
107
|
+
scope: {
|
|
108
|
+
judged: string[];
|
|
109
|
+
unavailable: string[];
|
|
110
|
+
};
|
|
111
|
+
};
|
|
98
112
|
}
|
|
99
113
|
/**
|
|
100
114
|
* @implements A-SPEC-534.4
|
|
@@ -62,6 +62,8 @@ const node_child_process_1 = require("node:child_process");
|
|
|
62
62
|
const path = __importStar(require("node:path"));
|
|
63
63
|
const test_scope_1 = require("../rtm/test-scope");
|
|
64
64
|
const constitution_1 = require("../governance/constitution");
|
|
65
|
+
// @implements A-SPEC-574.4 — same detector as the design-time advisory and the strict article.
|
|
66
|
+
const cycle_detect_1 = require("../cpg/cycle-detect");
|
|
65
67
|
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
66
68
|
const test_evidence_1 = require("../review/test-evidence");
|
|
67
69
|
const test_outcomes_1 = require("../review/test-outcomes");
|
|
@@ -439,6 +441,7 @@ function evaluateStop(specs, evidence) {
|
|
|
439
441
|
const violations = (0, constitution_1.verifyConstitution)({
|
|
440
442
|
specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings,
|
|
441
443
|
redFirstMode: evidence?.redFirstMode, changedAspecs: evidence?.changedAspecs, outcomesByAspec: evidence?.outcomesByAspec,
|
|
444
|
+
cycles: evidence?.cycles,
|
|
442
445
|
});
|
|
443
446
|
// @implements A-SPEC-534.4 — `track` records ART-8 findings without blocking the turn. Computed
|
|
444
447
|
// separately (the constitution stays silent on ART-8 outside strict) and returned in `tracked` for
|
|
@@ -449,6 +452,17 @@ function evaluateStop(specs, evidence) {
|
|
|
449
452
|
if (t.length)
|
|
450
453
|
tracked = t;
|
|
451
454
|
}
|
|
455
|
+
// @implements A-SPEC-574.4 — the cycle ratchet's track posture: recorded, never blocking. Promotion
|
|
456
|
+
// to `strict` waits on the observation ledger answering the false-positive rate, which is the same
|
|
457
|
+
// path impactAdvisory and anchorDensity took.
|
|
458
|
+
if (evidence?.cycles !== undefined && evidence.cycles.mode === 'track') {
|
|
459
|
+
const scope = evidence.cycles.scope.unavailable.length > 0
|
|
460
|
+
? ` (NOT judged: ${evidence.cycles.scope.unavailable.join(', ')})` : '';
|
|
461
|
+
const t = (0, cycle_detect_1.cycleRatchetViolations)(evidence.cycles.current, evidence.cycles.allowed)
|
|
462
|
+
.map((x) => ({ article: 'ART-2', detail: `code ${x.detail}${scope}` }));
|
|
463
|
+
if (t.length)
|
|
464
|
+
tracked = [...(tracked ?? []), ...t];
|
|
465
|
+
}
|
|
452
466
|
const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
|
|
453
467
|
// @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
|
|
454
468
|
// are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
|
|
@@ -876,6 +890,65 @@ if (require.main === module) {
|
|
|
876
890
|
changedAspecs = undefined;
|
|
877
891
|
outcomesByAspec = undefined;
|
|
878
892
|
}
|
|
893
|
+
// @implements A-SPEC-574.4 — the code graph's acyclicity, judged where the code exists. The
|
|
894
|
+
// scan is the same one every other layer uses; the classification reads only the files an
|
|
895
|
+
// import edge starts from. Fail-open in every direction: any error leaves the article silent,
|
|
896
|
+
// because "we could not look" must never be reported as "there is nothing there".
|
|
897
|
+
let cycles;
|
|
898
|
+
try {
|
|
899
|
+
const root = stopProjectRoot();
|
|
900
|
+
const { CpgScanner } = require('../cpg/cpg-scanner');
|
|
901
|
+
const scanned = new CpgScanner().scan(root, root);
|
|
902
|
+
const known = new Set(scanned.map((f) => f.sourcePath));
|
|
903
|
+
const edges = [];
|
|
904
|
+
const judged = new Set();
|
|
905
|
+
const sawImports = new Set();
|
|
906
|
+
for (const f of scanned) {
|
|
907
|
+
const ext = f.sourcePath.slice(f.sourcePath.lastIndexOf('.'));
|
|
908
|
+
let text = '';
|
|
909
|
+
try {
|
|
910
|
+
text = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
let sawImport = false;
|
|
916
|
+
for (const e of f.edges ?? []) {
|
|
917
|
+
if (e.rel !== 'imports' || !e.to)
|
|
918
|
+
continue;
|
|
919
|
+
sawImport = true;
|
|
920
|
+
if (!e.to.startsWith('.'))
|
|
921
|
+
continue;
|
|
922
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(f.sourcePath), e.to));
|
|
923
|
+
const to = ['.ts', '.tsx', '.js', '/index.ts'].map((x) => base + x).find((c) => known.has(c))
|
|
924
|
+
?? (known.has(base) ? base : null);
|
|
925
|
+
if (to === null || to === f.sourcePath)
|
|
926
|
+
continue;
|
|
927
|
+
edges.push({ from: f.sourcePath, to, kind: (0, cycle_detect_1.classifyEdgeByTarget)(text, f.sourcePath, to) });
|
|
928
|
+
judged.add(ext);
|
|
929
|
+
}
|
|
930
|
+
// A file whose imports are all bare specifiers is not evidence that its LANGUAGE is
|
|
931
|
+
// unresolvable — subtract the judged set at the end instead of marking per file, or one
|
|
932
|
+
// such file makes the whole language read as unjudged (measured: `.ts` and `.js` landed in
|
|
933
|
+
// both lists).
|
|
934
|
+
if (sawImport)
|
|
935
|
+
sawImports.add(ext);
|
|
936
|
+
}
|
|
937
|
+
cycles = {
|
|
938
|
+
current: (0, cycle_detect_1.detectCycles)(edges).cycles,
|
|
939
|
+
allowed: [],
|
|
940
|
+
mode: 'track',
|
|
941
|
+
// Unjudged = saw imports, resolved none, anywhere. Naming these is the difference between
|
|
942
|
+
// "no cycles" and "no cycles in the languages we could follow".
|
|
943
|
+
scope: {
|
|
944
|
+
judged: [...judged].sort(),
|
|
945
|
+
unavailable: [...sawImports].filter((e) => !judged.has(e)).sort(),
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
cycles = undefined;
|
|
951
|
+
}
|
|
879
952
|
// Provenance-chain verification (fail-open: a verify error skips the check, never crashes).
|
|
880
953
|
let provenance;
|
|
881
954
|
// @implements A-SPEC-148
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFUSE_TOP_FILES = 10;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFUSE_TOP_FILES = void 0;
|
|
4
|
+
// @implements A-SPEC-573.4
|
|
5
|
+
// One constant, imported by both consumers, so the product path and the benchmark cannot drift on
|
|
6
|
+
// how much data flow they read. Ten is the emission tier a caller acts on; whole-repository
|
|
7
|
+
// extraction was measured at +88.8% (2572ms -> 4857ms, 2026-09-08) and ten files at roughly 95ms.
|
|
8
|
+
exports.DEFUSE_TOP_FILES = 10;
|
|
@@ -223,6 +223,11 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
223
223
|
graphPreview?: {
|
|
224
224
|
impact?: import("../rtm/impact-advisory").ImpactAdvisory;
|
|
225
225
|
density?: import("../rtm/anchor-density").AnchorDensityFinding[];
|
|
226
|
+
cycles?: {
|
|
227
|
+
findings: import("../cpg/cycle-detect").CycleFinding[];
|
|
228
|
+
note: string;
|
|
229
|
+
};
|
|
230
|
+
architecture?: import("../cpg/arch-observe").ArchObservation[];
|
|
226
231
|
graphAsOf?: string;
|
|
227
232
|
} | undefined;
|
|
228
233
|
id: string;
|