@holmes-lab/holmes-kit 0.25.1 → 0.26.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 +70 -0
- package/README.md +8 -3
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.js +7 -26
- package/dist/holmes/cli/index.js +8 -1
- package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
- package/dist/holmes/cpg/cycle-observation.js +25 -2
- package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
- package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
- package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
- package/dist/holmes/cpg/forbidden-edges.js +135 -2
- package/dist/holmes/cpg/import-resolver.d.ts +12 -0
- package/dist/holmes/cpg/import-resolver.js +215 -0
- package/dist/holmes/cpg/language-capability.d.ts +14 -0
- package/dist/holmes/cpg/language-capability.js +37 -13
- package/dist/holmes/cpg/proposed-content.d.ts +7 -1
- package/dist/holmes/cpg/proposed-content.js +7 -0
- package/dist/holmes/governance/approval-queue.d.ts +33 -0
- package/dist/holmes/governance/approval-queue.js +85 -0
- package/dist/holmes/hooks/pre-tool-use.js +8 -1
- package/dist/holmes/hooks/session-start.js +39 -0
- package/dist/holmes/hooks/stop.d.ts +38 -2
- package/dist/holmes/hooks/stop.js +153 -49
- package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
- package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
- package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
- package/dist/holmes/project/report-briefing.d.ts +48 -0
- package/dist/holmes/project/report-briefing.js +70 -0
- package/dist/holmes/project/resolved-reports.d.ts +20 -0
- package/dist/holmes/project/resolved-reports.js +7 -0
- package/dist/holmes/rtm/rtm-builder.js +6 -141
- package/package.json +1 -1
- package/playbooks/publish/PLAYBOOK.md +18 -0
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.createImportResolver = createImportResolver;
|
|
37
|
+
// @implements A-SPEC-688
|
|
38
|
+
/**
|
|
39
|
+
* The ONE place an import specifier becomes a scanned file path.
|
|
40
|
+
*
|
|
41
|
+
* It used to be two. `rtm/addImportEdges` grew the per-language rules (see A-SPEC-289, A-SPEC-406,
|
|
42
|
+
* A-SPEC-525.1, A-SPEC-527.1) while the Stop hook's cycle ratchet re-implemented a crippled
|
|
43
|
+
* version beside it — relative specifiers only, four extensions. Measured 2026-09-18 on fixtures
|
|
44
|
+
* shaped the way each language's rules require: this resolver reached 18 of 20 specifiers and
|
|
45
|
+
* found all 9 planted cycles; the ratchet's copy reached 2 and found 1. The defect cost nothing in
|
|
46
|
+
* this repository (all TypeScript, where the two agree exactly: 919 edges each), which is why it
|
|
47
|
+
* survived to v0.23.2 — and everything in a consumer written in Java, Go, Python or Rust.
|
|
48
|
+
*
|
|
49
|
+
* Extracted here rather than left in `rtm/` because resolution is a fact about CODE, not about the
|
|
50
|
+
* requirement graph, and because `rtm/` and `hooks/` both already import `cpg/` — so this placement
|
|
51
|
+
* adds no layer edge. Choosing the direction at design time is the cheap moment; that is the whole
|
|
52
|
+
* subject of the slice this module belongs to.
|
|
53
|
+
*
|
|
54
|
+
* The founding rule is unchanged and is what makes every branch safe: resolve ONLY to a file the
|
|
55
|
+
* scan actually contains. An unresolvable specifier emits nothing rather than a guess.
|
|
56
|
+
*
|
|
57
|
+
* The rules below are MOVED, not rewritten. Their originating specs are cited by number rather
|
|
58
|
+
* than anchored, because the authority for this file is A-SPEC-688 alone.
|
|
59
|
+
*/
|
|
60
|
+
const fs = __importStar(require("node:fs"));
|
|
61
|
+
const path = __importStar(require("node:path"));
|
|
62
|
+
function fsReadGoMod(p) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.readFileSync(p, 'utf8').match(/^module\s+(\S+)/m)?.[1] ?? null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function createImportResolver(scanned, opts) {
|
|
71
|
+
const known = new Set(scanned.map((f) => f.sourcePath));
|
|
72
|
+
const EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx', '.py',
|
|
73
|
+
'/index.ts', '/index.js', '/__init__.py'];
|
|
74
|
+
const firstKnown = (base) => EXTENSIONS.map((ext) => `${base}${ext}`).find((candidate) => known.has(candidate)) ?? null;
|
|
75
|
+
// A-SPEC-525.1 — the five languages' resolution rules, selected by the IMPORTING file's
|
|
76
|
+
// extension because resolution IS per-language semantics. The founding rule is unchanged
|
|
77
|
+
// everywhere: resolve only to files the scan actually contains; the ambiguous resolve to none.
|
|
78
|
+
const knownList = [...known];
|
|
79
|
+
const uniqueSuffix = (suffix) => {
|
|
80
|
+
const hits = knownList.filter((k) => k === suffix || k.endsWith(`/${suffix}`));
|
|
81
|
+
return hits.length === 1 ? hits[0] : null; // two files may not become a guess
|
|
82
|
+
};
|
|
83
|
+
// go.mod discovery: injected via opts when the caller knows it; otherwise walked up from the
|
|
84
|
+
// first Go file's ABSOLUTE path (bounded), so production builds resolve without extra wiring.
|
|
85
|
+
let goModCache;
|
|
86
|
+
const goModuleOf = () => {
|
|
87
|
+
if (opts?.goModule)
|
|
88
|
+
return opts.goModule;
|
|
89
|
+
if (goModCache !== undefined)
|
|
90
|
+
return goModCache ?? undefined;
|
|
91
|
+
goModCache = null;
|
|
92
|
+
const anyGo = scanned.find((f) => /\.go$/.test(f.sourcePath) && f.path);
|
|
93
|
+
if (anyGo) {
|
|
94
|
+
let dir = path.dirname(anyGo.path);
|
|
95
|
+
for (let hops = 0; hops < 12; hops++) {
|
|
96
|
+
try {
|
|
97
|
+
const txt = fsReadGoMod(path.join(dir, 'go.mod'));
|
|
98
|
+
if (txt !== null) {
|
|
99
|
+
goModCache = txt;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch { /* keep walking */ }
|
|
104
|
+
const up = path.dirname(dir);
|
|
105
|
+
if (up === dir)
|
|
106
|
+
break;
|
|
107
|
+
dir = up;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return goModCache ?? undefined;
|
|
111
|
+
};
|
|
112
|
+
const goPackage = (spec) => {
|
|
113
|
+
const mod = goModuleOf();
|
|
114
|
+
if (!mod || !(spec === mod || spec.startsWith(`${mod}/`)))
|
|
115
|
+
return []; // external, honestly
|
|
116
|
+
const dir = spec === mod ? '' : spec.slice(mod.length + 1);
|
|
117
|
+
const prefix = dir === '' ? '' : `${dir}/`;
|
|
118
|
+
return knownList.filter((k) => k.startsWith(prefix) && k.endsWith('.go')
|
|
119
|
+
&& !k.endsWith('_test.go') && !k.slice(prefix.length).includes('/'));
|
|
120
|
+
};
|
|
121
|
+
const rustResolve = (fromFile, spec) => {
|
|
122
|
+
const segs = spec.split('::');
|
|
123
|
+
const head = segs.shift();
|
|
124
|
+
let baseDir;
|
|
125
|
+
if (head === 'crate') {
|
|
126
|
+
// the crate root is the src/ directory nearest above the importing file
|
|
127
|
+
const m = fromFile.match(/^(.*?src)\//);
|
|
128
|
+
baseDir = m ? m[1] : 'src';
|
|
129
|
+
}
|
|
130
|
+
else if (head === 'super') {
|
|
131
|
+
baseDir = path.posix.dirname(path.posix.dirname(fromFile));
|
|
132
|
+
while (segs[0] === 'super') {
|
|
133
|
+
// A-SPEC-527.1 — super:: above the crate root is an rustc error; resolving it pinned '.'
|
|
134
|
+
// and produced edges to repo-root files (adversarial sweep). Refuse.
|
|
135
|
+
if (baseDir === '.' || baseDir === '/')
|
|
136
|
+
return null;
|
|
137
|
+
segs.shift();
|
|
138
|
+
baseDir = path.posix.dirname(baseDir);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (head === 'self') {
|
|
142
|
+
baseDir = path.posix.dirname(fromFile);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
return null; // external crate
|
|
146
|
+
}
|
|
147
|
+
const tryPath = (parts) => {
|
|
148
|
+
if (parts.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
const base = path.posix.normalize(path.posix.join(baseDir, ...parts));
|
|
151
|
+
if (known.has(`${base}.rs`))
|
|
152
|
+
return `${base}.rs`;
|
|
153
|
+
if (known.has(`${base}/mod.rs`))
|
|
154
|
+
return `${base}/mod.rs`;
|
|
155
|
+
return null;
|
|
156
|
+
};
|
|
157
|
+
// the last segment may be an ITEM, not a module — drop it once and retry
|
|
158
|
+
return tryPath(segs) ?? tryPath(segs.slice(0, -1));
|
|
159
|
+
};
|
|
160
|
+
const resolve = (fromFile, spec) => {
|
|
161
|
+
// A-SPEC-525.1 — language branches BEFORE the JS/Python-shaped fallthrough.
|
|
162
|
+
if (/\.go$/.test(fromFile))
|
|
163
|
+
return null; // Go fans out separately (a package is its files)
|
|
164
|
+
if (/\.rs$/.test(fromFile))
|
|
165
|
+
return rustResolve(fromFile, spec);
|
|
166
|
+
if (/\.java$/.test(fromFile))
|
|
167
|
+
return uniqueSuffix(`${spec.split('.').join('/')}.java`);
|
|
168
|
+
if (/\.cs$/.test(fromFile))
|
|
169
|
+
return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
|
|
170
|
+
if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
|
|
171
|
+
// A-SPEC-527.1 — an ABSOLUTE include is not a repository coordinate: joining it into the
|
|
172
|
+
// repo frame let `#include "/etc/passwd"` match a coincidentally-shaped scanned file
|
|
173
|
+
// (adversarial sweep). Absolute means absolute; it resolves to nothing here.
|
|
174
|
+
if (spec.startsWith('/'))
|
|
175
|
+
return null;
|
|
176
|
+
// extension preserved: the dotted split below would butcher `util/env.h`
|
|
177
|
+
const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
|
|
178
|
+
if (known.has(relative))
|
|
179
|
+
return relative;
|
|
180
|
+
return known.has(spec) ? spec : null; // repo-root-relative include
|
|
181
|
+
}
|
|
182
|
+
// A-SPEC-406
|
|
183
|
+
// A specifier without a leading dot used to be refused outright, on the ground that it names an
|
|
184
|
+
// external package. That holds for `node:fs` and `js-yaml`; it does NOT hold for Python, which
|
|
185
|
+
// writes intra-repository modules absolutely — `from src.core.memory_audit import X`. Measured
|
|
186
|
+
// on the jarvis corpus: 1060 specifiers, 0 relative, 542 naming a file the scan already had, and
|
|
187
|
+
// 0 import edges in the graph. The whole import layer was missing for that language because the
|
|
188
|
+
// resolver's shape was JS's.
|
|
189
|
+
//
|
|
190
|
+
// The rule the original was written under is unchanged and is what makes this safe: resolve
|
|
191
|
+
// ONLY to a file the scan actually contains. `os.path` becomes `os/path.py`, which is in no
|
|
192
|
+
// scan, so it still emits nothing. Nothing is invented; the dotted form is simply also read.
|
|
193
|
+
if (!spec.startsWith('.')) {
|
|
194
|
+
if (spec.includes(':'))
|
|
195
|
+
return null; // `node:fs` and friends are never a repository path
|
|
196
|
+
return firstKnown(path.posix.normalize(spec.split('.').filter(Boolean).join('/')));
|
|
197
|
+
}
|
|
198
|
+
// A-SPEC-289
|
|
199
|
+
// Python writes a relative import as `.b` / `..pkg.mod`, where the leading dots are LEVELS and
|
|
200
|
+
// the remaining dots are path separators — not the `./b` form JS uses. Treating `.b` as a path
|
|
201
|
+
// yields the literal name `.b`, which matches nothing. Detected by the absence of a slash: a
|
|
202
|
+
// specifier that already contains one is a JS-style path and is left alone.
|
|
203
|
+
let relative = spec;
|
|
204
|
+
if (!spec.includes('/')) {
|
|
205
|
+
const dots = spec.match(/^\.+/)[0].length;
|
|
206
|
+
const rest = spec.slice(dots).split('.').filter(Boolean).join('/');
|
|
207
|
+
relative = `${'../'.repeat(Math.max(0, dots - 1)) || './'}${rest}`;
|
|
208
|
+
}
|
|
209
|
+
return firstKnown(path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), relative)));
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
resolve,
|
|
213
|
+
fanOut: (fromFile, spec) => (/\.go$/.test(fromFile) ? goPackage(spec) : []),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -54,6 +54,20 @@ export interface LanguageGap {
|
|
|
54
54
|
* meet would bury the ones this answer actually rests on. A language with nothing missing drops out
|
|
55
55
|
* entirely, so the report shrinks to nothing as extraction catches up rather than becoming noise.
|
|
56
56
|
*/
|
|
57
|
+
/**
|
|
58
|
+
* @implements A-SPEC-689
|
|
59
|
+
* The gap shape for ONE capability, as a pure function of it.
|
|
60
|
+
*
|
|
61
|
+
* Split out so the concept can be pinned on a constructed capability instead of on whichever real
|
|
62
|
+
* language happens to lag. That was already the stated intent of the sibling test, but not its
|
|
63
|
+
* practice: the pin was moved from `.java` to `.cjs` when Java graduated, and `.cjs` has now
|
|
64
|
+
* graduated too, which would have moved it a third time — to nothing, because no advertised
|
|
65
|
+
* extension lags any more. A test that must be rewritten every time the product improves is not
|
|
66
|
+
* pinning the product; it is following it.
|
|
67
|
+
*
|
|
68
|
+
* Returns null when the capability has no gap at all.
|
|
69
|
+
*/
|
|
70
|
+
export declare function gapOf(ext: string, cap: LanguageCapability): LanguageGap | null;
|
|
57
71
|
export declare function capabilityGapsFor(sourcePaths: readonly string[]): LanguageGap[];
|
|
58
72
|
/** Every advertised extension must have an entry; a blank would be a silent overclaim. */
|
|
59
73
|
export declare const ADVERTISED_EXTENSIONS: readonly string[];
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.ADVERTISED_EXTENSIONS = exports.LANGUAGE_CAPABILITY = exports.EXTRACTABLE_RELATIONS = void 0;
|
|
37
37
|
exports.capabilityFor = capabilityFor;
|
|
38
|
+
exports.gapOf = gapOf;
|
|
38
39
|
exports.capabilityGapsFor = capabilityGapsFor;
|
|
39
40
|
exports.languageFamilyOf = languageFamilyOf;
|
|
40
41
|
// @implements A-SPEC-289
|
|
@@ -67,8 +68,9 @@ exports.EXTRACTABLE_RELATIONS = ['calls', 'imports', 'inherits'];
|
|
|
67
68
|
// @implements A-SPEC-289 — imports now resolve to FILE nodes when the specifier is relative and
|
|
68
69
|
// names a scanned file; bare specifiers stay external and unresolved by design.
|
|
69
70
|
const TS_FAMILY = { symbols: true, relations: ['calls', 'imports', 'inherits'], graphResolved: ['calls', 'imports', 'inherits'] };
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
// @implements A-SPEC-689 — CALLS_ONLY used to live here, for `.cjs` alone. `.cjs` graduated once it
|
|
72
|
+
// was measured rather than assumed, leaving the shape with no member; the history it carried now
|
|
73
|
+
// lives on the `.cjs` cell itself, where the correction can be read next to the value.
|
|
72
74
|
// @implements A-SPEC-511.1
|
|
73
75
|
/**
|
|
74
76
|
* Calls AND inheritance. The five non-TS/Python languages moved here once their inheritance was
|
|
@@ -98,8 +100,16 @@ const FULL_RELATIONS = {
|
|
|
98
100
|
exports.LANGUAGE_CAPABILITY = {
|
|
99
101
|
'.ts': TS_FAMILY, '.mts': TS_FAMILY, '.cts': TS_FAMILY, '.tsx': TS_FAMILY,
|
|
100
102
|
'.js': TS_FAMILY, '.mjs': TS_FAMILY, '.jsx': TS_FAMILY,
|
|
101
|
-
//
|
|
102
|
-
'
|
|
103
|
+
// @implements A-SPEC-689 — `.cjs` is the TS family, measured rather than assumed.
|
|
104
|
+
// This cell said CALLS_ONLY on the strength of `require('os')` producing no import. That much is
|
|
105
|
+
// true and stays pinned: an external package has no node to point at. But it was written as a
|
|
106
|
+
// statement about `require()` itself, and it is not. Measured 2026-09-18: a RELATIVE
|
|
107
|
+
// `require('./b')` IS recovered as an import, and `class Child extends Base` as an inherit, in
|
|
108
|
+
// `.cjs` exactly as in `.js`. The table under-declared two relations for as long as the only
|
|
109
|
+
// pinned case was the bare one — an error with no visible symptom, which is why it lasted. The
|
|
110
|
+
// sibling test now pins both forms, so the claim above this table is finally true in BOTH
|
|
111
|
+
// directions.
|
|
112
|
+
'.cjs': TS_FAMILY,
|
|
103
113
|
// @implements A-SPEC-286 — the first language whose inheritance is both extracted AND resolved.
|
|
104
114
|
'.py': { symbols: true, relations: ['calls', 'imports', 'inherits'], graphResolved: ['calls', 'imports', 'inherits'] },
|
|
105
115
|
// @implements A-SPEC-511.1 — inheritance recovered: Java extends/implements, C# base_list,
|
|
@@ -122,6 +132,27 @@ function capabilityFor(ext) {
|
|
|
122
132
|
* meet would bury the ones this answer actually rests on. A language with nothing missing drops out
|
|
123
133
|
* entirely, so the report shrinks to nothing as extraction catches up rather than becoming noise.
|
|
124
134
|
*/
|
|
135
|
+
/**
|
|
136
|
+
* @implements A-SPEC-689
|
|
137
|
+
* The gap shape for ONE capability, as a pure function of it.
|
|
138
|
+
*
|
|
139
|
+
* Split out so the concept can be pinned on a constructed capability instead of on whichever real
|
|
140
|
+
* language happens to lag. That was already the stated intent of the sibling test, but not its
|
|
141
|
+
* practice: the pin was moved from `.java` to `.cjs` when Java graduated, and `.cjs` has now
|
|
142
|
+
* graduated too, which would have moved it a third time — to nothing, because no advertised
|
|
143
|
+
* extension lags any more. A test that must be rewritten every time the product improves is not
|
|
144
|
+
* pinning the product; it is following it.
|
|
145
|
+
*
|
|
146
|
+
* Returns null when the capability has no gap at all.
|
|
147
|
+
*/
|
|
148
|
+
function gapOf(ext, cap) {
|
|
149
|
+
const gap = {
|
|
150
|
+
ext,
|
|
151
|
+
missing: exports.EXTRACTABLE_RELATIONS.filter((r) => !cap.relations.includes(r)),
|
|
152
|
+
extractedButUnresolved: cap.relations.filter((r) => !cap.graphResolved.includes(r)),
|
|
153
|
+
};
|
|
154
|
+
return gap.missing.length > 0 || gap.extractedButUnresolved.length > 0 ? gap : null;
|
|
155
|
+
}
|
|
125
156
|
function capabilityGapsFor(sourcePaths) {
|
|
126
157
|
const present = new Set();
|
|
127
158
|
for (const p of sourcePaths) {
|
|
@@ -130,15 +161,8 @@ function capabilityGapsFor(sourcePaths) {
|
|
|
130
161
|
present.add(ext);
|
|
131
162
|
}
|
|
132
163
|
return [...present].sort()
|
|
133
|
-
.map((ext) =>
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
ext,
|
|
137
|
-
missing: exports.EXTRACTABLE_RELATIONS.filter((r) => !cap.relations.includes(r)),
|
|
138
|
-
extractedButUnresolved: cap.relations.filter((r) => !cap.graphResolved.includes(r)),
|
|
139
|
-
};
|
|
140
|
-
})
|
|
141
|
-
.filter((gap) => gap.missing.length > 0 || gap.extractedButUnresolved.length > 0);
|
|
164
|
+
.map((ext) => gapOf(ext, capabilityFor(ext)))
|
|
165
|
+
.filter((gap) => gap !== null);
|
|
142
166
|
}
|
|
143
167
|
/** Every advertised extension must have an entry; a blank would be a silent overclaim. */
|
|
144
168
|
exports.ADVERTISED_EXTENSIONS = cpg_scanner_1.SCANNABLE_EXTENSIONS;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { CodeEdge } from './language-parser';
|
|
2
|
-
import { ForbiddenEdgeRule } from './forbidden-edges';
|
|
2
|
+
import { ForbiddenEdgeRule, type BaselineEntry } from './forbidden-edges';
|
|
3
3
|
import { RequiredCallRule } from './required-calls';
|
|
4
4
|
/** What the gate could actually see. `none` means no verdict was formed, not that nothing was wrong. */
|
|
5
5
|
export type JudgedAs = 'full' | 'fragment' | 'none';
|
|
6
6
|
export interface ProposedContentCheck {
|
|
7
7
|
forbidden: ForbiddenEdgeRule[];
|
|
8
|
+
/**
|
|
9
|
+
* @implements A-SPEC-691
|
|
10
|
+
* Violations the project declared it already carries. Optional so every existing caller keeps
|
|
11
|
+
* working unchanged — absent means "no allowances", which is what the gate did before.
|
|
12
|
+
*/
|
|
13
|
+
baselines?: BaselineEntry[];
|
|
8
14
|
required: RequiredCallRule[];
|
|
9
15
|
sourcePath: string;
|
|
10
16
|
content: string;
|
|
@@ -51,6 +51,13 @@ function checkProposedContent(opts) {
|
|
|
51
51
|
const asScanned = [{ path: sourcePath, sourcePath, symbols: [], edges, implementsSpecs: [] }];
|
|
52
52
|
const violations = [];
|
|
53
53
|
for (const v of (0, forbidden_edges_1.findForbiddenEdgeViolations)(asScanned, forbidden ?? [])) {
|
|
54
|
+
// @implements A-SPEC-691 — a violation the project already declared it carries is not a denial.
|
|
55
|
+
// Without this the gate would be live while the escape hatch stayed dead: a project with legacy
|
|
56
|
+
// could declare a rule and then be unable to edit the files it named. "A rule that blocks
|
|
57
|
+
// adoption is not a rule, it is a barrier" (REQ-574). The predicate is SHARED with the
|
|
58
|
+
// scan-wide path, so a baseline cannot mean one thing in a report and another at the gate.
|
|
59
|
+
if ((0, forbidden_edges_1.isBaselined)(v, opts.baselines ?? []))
|
|
60
|
+
continue;
|
|
54
61
|
violations.push(`${v.sourcePath}: ${v.rule} (${v.kind} → ${v.to})`);
|
|
55
62
|
}
|
|
56
63
|
// Obligations only where the whole file is visible — see the asymmetry above.
|
|
@@ -202,3 +202,36 @@ export declare function queueHint(root: string, req: {
|
|
|
202
202
|
} & RequestDetails, opts?: {
|
|
203
203
|
baseReasonBytes?: number;
|
|
204
204
|
}): string;
|
|
205
|
+
/**
|
|
206
|
+
* @implements A-SPEC-690
|
|
207
|
+
* Every request an operator can name — the candidate set a REFERENCE resolves against.
|
|
208
|
+
*
|
|
209
|
+
* The tracked queue is a decision inbox and deliberately excludes gate refusals (A-SPEC-563.1,
|
|
210
|
+
* measured 1,412 requests against 8 decisions). That routing is right and this does not undo it:
|
|
211
|
+
* the LIST still shows the inbox. But a reference is not a list — it is the operator naming the
|
|
212
|
+
* id the refusal just printed, and A-SPEC-576.1 already says such an id "must never make
|
|
213
|
+
* ungrantable". It did: `--grant <id>` resolved against the queue alone, so a shell refusal failed
|
|
214
|
+
* at reference resolution and never reached the refusal-log fallback that approve.ts already has.
|
|
215
|
+
*
|
|
216
|
+
* Measured 2026-09-19: `readRefusals` found req-9e92e52bd233 while `approve --grant` refused it.
|
|
217
|
+
*/
|
|
218
|
+
export declare function grantableRequests(root: string): PendingRequest[];
|
|
219
|
+
/**
|
|
220
|
+
* @implements A-SPEC-690
|
|
221
|
+
* Which occurrences a decision has NOT already covered.
|
|
222
|
+
*
|
|
223
|
+
* Shared with `approve.ts`'s own lookup so the two never disagree about whether a request is open.
|
|
224
|
+
* A decision records HOW MANY occurrences it covered, so a retry is everything past that count —
|
|
225
|
+
* millisecond ties (measured flaky under the full suite) cannot lose it. Decisions written before
|
|
226
|
+
* the count existed fall back to the timestamp rule.
|
|
227
|
+
*/
|
|
228
|
+
export declare function openOccurrences(all: readonly RefusalRecord[], decided?: {
|
|
229
|
+
ts: string;
|
|
230
|
+
covered?: number;
|
|
231
|
+
}): RefusalRecord[];
|
|
232
|
+
/**
|
|
233
|
+
* @implements A-SPEC-690
|
|
234
|
+
* One id's refusals, in the shape a queue entry has — so the decision surface reads the same
|
|
235
|
+
* whichever store the request came from.
|
|
236
|
+
*/
|
|
237
|
+
export declare function asPendingRequest(id: string, occurrences: readonly RefusalRecord[]): PendingRequest;
|
|
@@ -44,6 +44,9 @@ exports.enqueueApprovalRequestDetailed = enqueueApprovalRequestDetailed;
|
|
|
44
44
|
exports.appendQueueEvent = appendQueueEvent;
|
|
45
45
|
exports.readQueue = readQueue;
|
|
46
46
|
exports.queueHint = queueHint;
|
|
47
|
+
exports.grantableRequests = grantableRequests;
|
|
48
|
+
exports.openOccurrences = openOccurrences;
|
|
49
|
+
exports.asPendingRequest = asPendingRequest;
|
|
47
50
|
// @implements A-SPEC-244, A-SPEC-626
|
|
48
51
|
const execution_context_1 = require("../project/execution-context");
|
|
49
52
|
const root_1 = require("../project/root");
|
|
@@ -503,3 +506,85 @@ function queueHint(root, req, opts) {
|
|
|
503
506
|
? hint
|
|
504
507
|
: denialLine;
|
|
505
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* @implements A-SPEC-690
|
|
511
|
+
* Every request an operator can name — the candidate set a REFERENCE resolves against.
|
|
512
|
+
*
|
|
513
|
+
* The tracked queue is a decision inbox and deliberately excludes gate refusals (A-SPEC-563.1,
|
|
514
|
+
* measured 1,412 requests against 8 decisions). That routing is right and this does not undo it:
|
|
515
|
+
* the LIST still shows the inbox. But a reference is not a list — it is the operator naming the
|
|
516
|
+
* id the refusal just printed, and A-SPEC-576.1 already says such an id "must never make
|
|
517
|
+
* ungrantable". It did: `--grant <id>` resolved against the queue alone, so a shell refusal failed
|
|
518
|
+
* at reference resolution and never reached the refusal-log fallback that approve.ts already has.
|
|
519
|
+
*
|
|
520
|
+
* Measured 2026-09-19: `readRefusals` found req-9e92e52bd233 while `approve --grant` refused it.
|
|
521
|
+
*/
|
|
522
|
+
function grantableRequests(root) {
|
|
523
|
+
const state = readQueue(root, { includeAllKinds: true });
|
|
524
|
+
const seen = new Set(state.pending.map((p) => p.id));
|
|
525
|
+
let refusals;
|
|
526
|
+
// Fail toward REFUSAL, never toward approval: an unreadable log removes candidates, so a
|
|
527
|
+
// reference stops resolving and no grant goes out. The opposite bias would turn a read error
|
|
528
|
+
// into an approval path.
|
|
529
|
+
try {
|
|
530
|
+
refusals = readRefusals(root);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
refusals = [];
|
|
534
|
+
}
|
|
535
|
+
const byId = new Map();
|
|
536
|
+
for (const r of refusals) {
|
|
537
|
+
const list = byId.get(r.id);
|
|
538
|
+
if (list)
|
|
539
|
+
list.push(r);
|
|
540
|
+
else
|
|
541
|
+
byId.set(r.id, [r]);
|
|
542
|
+
}
|
|
543
|
+
const out = [...state.pending];
|
|
544
|
+
for (const [id, all] of byId) {
|
|
545
|
+
// The queue copy carries the decision history the single-use rule reads; the refusal copy does
|
|
546
|
+
// not. When both exist the queue wins, or granting once would stop closing the request.
|
|
547
|
+
if (seen.has(id))
|
|
548
|
+
continue;
|
|
549
|
+
const live = openOccurrences(all, state.decisions[id]);
|
|
550
|
+
if (live.length === 0)
|
|
551
|
+
continue;
|
|
552
|
+
out.push(asPendingRequest(id, live));
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* @implements A-SPEC-690
|
|
558
|
+
* Which occurrences a decision has NOT already covered.
|
|
559
|
+
*
|
|
560
|
+
* Shared with `approve.ts`'s own lookup so the two never disagree about whether a request is open.
|
|
561
|
+
* A decision records HOW MANY occurrences it covered, so a retry is everything past that count —
|
|
562
|
+
* millisecond ties (measured flaky under the full suite) cannot lose it. Decisions written before
|
|
563
|
+
* the count existed fall back to the timestamp rule.
|
|
564
|
+
*/
|
|
565
|
+
function openOccurrences(all, decided) {
|
|
566
|
+
if (!decided)
|
|
567
|
+
return [...all];
|
|
568
|
+
if (typeof decided.covered === 'number')
|
|
569
|
+
return all.slice(decided.covered);
|
|
570
|
+
return all.filter((r) => r.ts > decided.ts);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* @implements A-SPEC-690
|
|
574
|
+
* One id's refusals, in the shape a queue entry has — so the decision surface reads the same
|
|
575
|
+
* whichever store the request came from.
|
|
576
|
+
*/
|
|
577
|
+
function asPendingRequest(id, occurrences) {
|
|
578
|
+
const latest = occurrences[occurrences.length - 1];
|
|
579
|
+
return {
|
|
580
|
+
id,
|
|
581
|
+
// The NEWEST occurrence is what the operator just saw, so it is what the decision names.
|
|
582
|
+
kind: latest.kind,
|
|
583
|
+
target: latest.target,
|
|
584
|
+
why: latest.why,
|
|
585
|
+
count: occurrences.length,
|
|
586
|
+
firstTs: occurrences[0].ts,
|
|
587
|
+
lastTs: latest.ts,
|
|
588
|
+
...(latest.replica ? { replica: latest.replica } : {}),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
@@ -1156,15 +1156,22 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
1156
1156
|
if (proposed && (0, write_target_1.specTargetOf)(opts.projectRoot, specsDir, p) === null) {
|
|
1157
1157
|
const forbidden = [];
|
|
1158
1158
|
const required = [];
|
|
1159
|
+
// @implements A-SPEC-691 — the allowances travel with the rules. They come out of the SAME
|
|
1160
|
+
// section and the same parse, so a rule and the baseline that tempers it can never be read
|
|
1161
|
+
// from documents that disagree.
|
|
1162
|
+
const baselines = [];
|
|
1159
1163
|
for (const sp of specs) {
|
|
1160
1164
|
if (sp.type !== 'C-SPEC' || sp.status !== 'approved')
|
|
1161
1165
|
continue;
|
|
1162
|
-
|
|
1166
|
+
const parsed = (0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '');
|
|
1167
|
+
forbidden.push(...parsed.rules);
|
|
1168
|
+
baselines.push(...parsed.baselines);
|
|
1163
1169
|
required.push(...(0, required_calls_1.parseRequiredCalls)(sp.sections?.['Layer Rules'] ?? '').rules);
|
|
1164
1170
|
}
|
|
1165
1171
|
if (forbidden.length > 0 || required.length > 0) {
|
|
1166
1172
|
const { violations } = (0, proposed_content_1.checkProposedContent)({
|
|
1167
1173
|
forbidden,
|
|
1174
|
+
baselines,
|
|
1168
1175
|
required,
|
|
1169
1176
|
// @implements A-SPEC-231 — the path is normalised before the prefix test. Adversarial
|
|
1170
1177
|
// pass 3, 2026-08-22: `./src/a/x.ts` and `src//a/x.ts` both walked past a rule scoped to
|
|
@@ -197,6 +197,45 @@ if (require.main === module) {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
catch { /* the banner is never a gate */ }
|
|
200
|
+
// @implements A-SPEC-687 — the other end of the loop `report` opened. Until now nothing read
|
|
201
|
+
// reported.jsonl, so a consumer who took the trouble to report a defect learned nothing when
|
|
202
|
+
// it was fixed. A LOCAL set intersection: a file this machine wrote against a list that
|
|
203
|
+
// shipped in the package. Nothing is sent; there is no device id and no MAC address, because
|
|
204
|
+
// a fingerprint identifies a DEFECT and not a person. Said once, then never again.
|
|
205
|
+
try {
|
|
206
|
+
const { RESOLVED_REPORTS } = require('../project/resolved-reports');
|
|
207
|
+
const { briefingLines, reportedFrom, announcedFrom, announcedTo } = require('../project/report-briefing');
|
|
208
|
+
const dir = path.join(process.cwd(), '.ax', 'reports');
|
|
209
|
+
const read = (f) => { try {
|
|
210
|
+
return fs.readFileSync(path.join(dir, f), 'utf8');
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
} };
|
|
215
|
+
const announced = announcedFrom(read('briefed.json'));
|
|
216
|
+
const lines = briefingLines({
|
|
217
|
+
reported: reportedFrom(read('reported.jsonl')),
|
|
218
|
+
resolved: RESOLVED_REPORTS,
|
|
219
|
+
installed: pkgVersion(),
|
|
220
|
+
shipped: RESOLVED_REPORTS.map((r) => r.version),
|
|
221
|
+
announced,
|
|
222
|
+
});
|
|
223
|
+
if (lines.length > 0) {
|
|
224
|
+
out.hookSpecificOutput.additionalContext += '\n[Holmes-Kit] ' + lines.join('\n[Holmes-Kit] ');
|
|
225
|
+
// Written ONLY when something was said: a marker recording an announcement that never
|
|
226
|
+
// happened would silence a future briefing that should have spoken.
|
|
227
|
+
const said = new Set(announced);
|
|
228
|
+
for (const r of RESOLVED_REPORTS)
|
|
229
|
+
if (lines.some((l) => l.includes(r.fingerprint)))
|
|
230
|
+
said.add(r.fingerprint);
|
|
231
|
+
try {
|
|
232
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
233
|
+
fs.writeFileSync(path.join(dir, 'briefed.json'), announcedTo(said));
|
|
234
|
+
}
|
|
235
|
+
catch { /* the briefing is delivered; recording it is best-effort */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { /* the banner is never a gate */ }
|
|
200
239
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: out.hookSpecificOutput }));
|
|
201
240
|
if (out.shouldRefresh) {
|
|
202
241
|
// Detached, unref'd child so the session start does not wait on the network. The refresh
|
|
@@ -5,6 +5,10 @@ import { type KnownDefectJudgement } from '../rtm/known-defects';
|
|
|
5
5
|
import { type CiVerdict } from '../project/ci-runs';
|
|
6
6
|
import { type DistVerdict } from '../project/dist-freshness';
|
|
7
7
|
import { type AnalysisVerdict } from '../project/analysis-currency';
|
|
8
|
+
import { judgeForbiddenEdges } from '../cpg/forbidden-edges';
|
|
9
|
+
import type { ScannedFile } from '../cpg/cpg-scanner';
|
|
10
|
+
/** @implements A-SPEC-693 */
|
|
11
|
+
export type ForbiddenEdgeJudgement = ReturnType<typeof judgeForbiddenEdges>;
|
|
8
12
|
export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
|
|
9
13
|
export declare function collectSemanticCoverage(root: string, tier?: {
|
|
10
14
|
tier: string;
|
|
@@ -55,6 +59,11 @@ export interface StopEvidence {
|
|
|
55
59
|
* turn's source edits. Absent when the workspace never analysed anything.
|
|
56
60
|
*/
|
|
57
61
|
analysis?: AnalysisVerdict;
|
|
62
|
+
/**
|
|
63
|
+
* @implements A-SPEC-693 — the scan-wide forbidden-edge judgement over the approved C-SPECs.
|
|
64
|
+
* Absent when the project declares no rule, or when the hook could not look; never a block.
|
|
65
|
+
*/
|
|
66
|
+
forbiddenEdges?: ForbiddenEdgeJudgement;
|
|
58
67
|
/** @implements A-SPEC-683 — the rendered coverage line the refresh child recorded; already judged. */
|
|
59
68
|
semantic?: string;
|
|
60
69
|
/** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
|
|
@@ -315,7 +324,34 @@ export declare function __setWiredSpecsForTest(v: string | undefined): void;
|
|
|
315
324
|
export declare function guardCountOrZero(sessionId: string, read?: (id: string) => number): number;
|
|
316
325
|
export declare function readGuardCount(sessionId: string): number;
|
|
317
326
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
327
|
+
* @implements A-SPEC-693
|
|
328
|
+
* The scan-wide forbidden-edge judgement, over the SAME rules the pre-edit gate reads: approved
|
|
329
|
+
* C-SPECs only, rules and baselines out of one parse of one section.
|
|
330
|
+
*
|
|
331
|
+
* Returns `undefined` when no rule is declared — the project never adopted this, so it pays nothing
|
|
332
|
+
* and hears nothing. The scan is handed in rather than made here: the Stop hook already scans once a
|
|
333
|
+
* turn for the cycle ratchet, and scanning is that hook's dominant cost.
|
|
334
|
+
*/
|
|
335
|
+
export declare function collectForbiddenEdgeJudgement(specs: readonly Spec[], scanned: ScannedFile[], now: Date): ForbiddenEdgeJudgement | undefined;
|
|
336
|
+
/**
|
|
337
|
+
* @implements A-SPEC-688
|
|
338
|
+
* The code graph's cycles, judged where the code exists — extracted from the hook body so the
|
|
339
|
+
* judgement is observable without driving a whole Stop turn.
|
|
340
|
+
*
|
|
341
|
+
* Returns `undefined` when the scan could not run at all. That is NOT "no cycles": the caller must
|
|
342
|
+
* keep the article silent rather than report a clean tree, because "we could not look" and "there
|
|
343
|
+
* is nothing there" are different facts (REQ-574).
|
|
344
|
+
*
|
|
345
|
+
* @implements A-SPEC-693 — `shared` is a scan the caller already made. When given it is BELIEVED
|
|
346
|
+
* and no second scan runs, so one turn pays for one scan however many judgements read it.
|
|
320
347
|
*/
|
|
348
|
+
export declare function scanCodeCycles(root: string, shared?: ScannedFile[]): {
|
|
349
|
+
current: import('../cpg/cycle-detect').Cycle[];
|
|
350
|
+
allowed: string[];
|
|
351
|
+
mode: 'strict' | 'track' | 'off';
|
|
352
|
+
scope: {
|
|
353
|
+
judged: string[];
|
|
354
|
+
unavailable: string[];
|
|
355
|
+
};
|
|
356
|
+
} | undefined;
|
|
321
357
|
export declare function writeGuardCount(sessionId: string, n: number): boolean;
|