@holmes-lab/holmes-kit 0.20.2 → 0.22.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 +100 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.js +1 -0
- package/dist/holmes/cli/doctor.d.ts +1 -0
- package/dist/holmes/cli/doctor.js +42 -0
- package/dist/holmes/cli/index.js +1 -0
- package/dist/holmes/cli/init.js +1 -0
- package/dist/holmes/cpg/language-parser-walk.js +1 -0
- package/dist/holmes/governance/constitution.d.ts +11 -0
- package/dist/holmes/governance/constitution.js +15 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
- package/dist/holmes/guardrail/impact-gate.js +19 -0
- package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
- package/dist/holmes/guardrail/risk-classifier.js +26 -3
- package/dist/holmes/hooks/stop.d.ts +16 -0
- package/dist/holmes/hooks/stop.js +116 -1
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +28 -1
- package/dist/holmes/mcp/handlers/operator-inspection.js +91 -3
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +6 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +87 -1
- package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
- package/dist/holmes/mcp/handlers/test-execution.js +6 -2
- package/dist/holmes/mcp/handlers.d.ts +34 -1
- package/dist/holmes/mcp/handlers.js +5 -0
- package/dist/holmes/mcp/maintenance-analyze.js +1 -0
- package/dist/holmes/mcp/tool-schemas.js +1 -0
- package/dist/holmes/project/ci-runs.d.ts +46 -0
- package/dist/holmes/project/ci-runs.js +137 -0
- package/dist/holmes/project/install-scripts-policy.js +1 -0
- package/dist/holmes/review/evaluation-metrics.js +1 -0
- package/dist/holmes/review/kills-check.d.ts +40 -0
- package/dist/holmes/review/kills-check.js +147 -0
- package/dist/holmes/review/manual-baseline.js +1 -0
- package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
- package/dist/holmes/rtm/advisory-outcomes.js +314 -0
- package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
- package/dist/holmes/rtm/anchor-comment.js +8 -0
- package/dist/holmes/rtm/file-anchors.d.ts +9 -0
- package/dist/holmes/rtm/file-anchors.js +128 -0
- package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
- package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
- package/dist/holmes/rtm/known-defects.d.ts +26 -0
- package/dist/holmes/rtm/known-defects.js +77 -0
- package/dist/holmes/rtm/link-census.d.ts +61 -0
- package/dist/holmes/rtm/link-census.js +90 -0
- package/dist/holmes/rtm/rtm-graph.js +1 -0
- package/dist/holmes/rtm/taint-benchmark.js +1 -0
- package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
- package/dist/holmes/rtm/trace-gaps.js +64 -0
- package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
- package/dist/holmes/server/dashboard-launcher.js +24 -1
- package/dist/holmes/server/dashboard.js +40 -2
- package/package.json +1 -1
- package/playbooks/author-slice/PLAYBOOK.md +30 -0
- package/playbooks/tdd-slice/PLAYBOOK.md +4 -0
|
@@ -0,0 +1,314 @@
|
|
|
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.ADVISORY_KINDS = void 0;
|
|
37
|
+
exports.canonical = canonical;
|
|
38
|
+
exports.advisoryId = advisoryId;
|
|
39
|
+
exports.foldAdvisoryState = foldAdvisoryState;
|
|
40
|
+
exports.judgeAdvisories = judgeAdvisories;
|
|
41
|
+
exports.dismissAdvisories = dismissAdvisories;
|
|
42
|
+
exports.advisoryCensus = advisoryCensus;
|
|
43
|
+
exports.appendAdvisoryOutcomes = appendAdvisoryOutcomes;
|
|
44
|
+
exports.readAdvisoryOutcomes = readAdvisoryOutcomes;
|
|
45
|
+
exports.traceGapAdvisories = traceGapAdvisories;
|
|
46
|
+
exports.currentAdvisoryKeys = currentAdvisoryKeys;
|
|
47
|
+
exports.recordAdvisoryOutcomes = recordAdvisoryOutcomes;
|
|
48
|
+
exports.traceGapFiles = traceGapFiles;
|
|
49
|
+
exports.gitHeadOf = gitHeadOf;
|
|
50
|
+
exports.traceGapFor = traceGapFor;
|
|
51
|
+
// @implements A-SPEC-663
|
|
52
|
+
/**
|
|
53
|
+
* The advisory REACTION ledger — the numerator every promotion decision has been missing.
|
|
54
|
+
*
|
|
55
|
+
* This repository already issues five advisory kinds (impact-advisory, anchor-density, ftt-fulfilment,
|
|
56
|
+
* trace-gap, kills-unapplicable) and records the ISSUE of most of them. Measured 2026-09-18: 77
|
|
57
|
+
* impact-advisory rows, 76 anchor-density rows, and not one row anywhere saying what the author did
|
|
58
|
+
* next. Tricorder's "not useful" button and Google's "productive mutant" metric count the reaction,
|
|
59
|
+
* not the emission; without that count "promote to a hard gate once the ledger answers the false-
|
|
60
|
+
* positive rate" has no ledger to ask.
|
|
61
|
+
*
|
|
62
|
+
* Pure: identity, judgement and folding take rows in and give rows out. The append/read helpers are
|
|
63
|
+
* the only I/O and follow the other advisory ledgers (per-replica jsonl, repo-relative content only).
|
|
64
|
+
*/
|
|
65
|
+
const crypto = __importStar(require("node:crypto"));
|
|
66
|
+
const node_child_process_1 = require("node:child_process");
|
|
67
|
+
const fs = __importStar(require("node:fs"));
|
|
68
|
+
const path = __importStar(require("node:path"));
|
|
69
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
70
|
+
const ftt_fulfilment_1 = require("./ftt-fulfilment");
|
|
71
|
+
const trace_gaps_1 = require("./trace-gaps");
|
|
72
|
+
/** Canonical JSON: sorted keys, arrays kept in order — the same payload always gives the same id. */
|
|
73
|
+
function canonical(value) {
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
76
|
+
if (value && typeof value === 'object') {
|
|
77
|
+
const o = value;
|
|
78
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`).join(',')}}`;
|
|
79
|
+
}
|
|
80
|
+
return JSON.stringify(value);
|
|
81
|
+
}
|
|
82
|
+
/** Deterministic identity of one finding: kind + spec + canonical payload → sha256, first 16 hex. */
|
|
83
|
+
function advisoryId(kind, aspec, payload) {
|
|
84
|
+
return crypto.createHash('sha256').update(`${kind}|${aspec}|${canonical(payload)}`).digest('hex').slice(0, 16);
|
|
85
|
+
}
|
|
86
|
+
/** Latest state per id, in first-seen order. `dismissed` and `resolved` are terminal until re-issued. */
|
|
87
|
+
function foldAdvisoryState(rows) {
|
|
88
|
+
const byId = new Map();
|
|
89
|
+
for (const r of rows) {
|
|
90
|
+
const cur = byId.get(r.id);
|
|
91
|
+
if (!cur) {
|
|
92
|
+
byId.set(r.id, { id: r.id, kind: r.kind, aspec: r.aspec, firstSeen: r.firstSeen, last: r.outcome, lastAt: r.judgedAt, rows: 1 });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
cur.rows += 1;
|
|
96
|
+
if (r.judgedAt >= cur.lastAt) {
|
|
97
|
+
cur.last = r.outcome;
|
|
98
|
+
cur.lastAt = r.judgedAt;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...byId.values()];
|
|
102
|
+
}
|
|
103
|
+
const day = (iso) => iso.slice(0, 10);
|
|
104
|
+
/**
|
|
105
|
+
* Judge the advisories a re-evaluation produced against what the ledger already knows.
|
|
106
|
+
* - a current finding with no state, or whose last state is resolved/dismissed → `issued`
|
|
107
|
+
* - a current finding whose last state is issued/persisted → `persisted`
|
|
108
|
+
* - an open (issued/persisted) finding of the judged scope that is no longer current → `resolved`
|
|
109
|
+
* One row per (id, outcome) per UTC day: re-running the same query the same day adds nothing.
|
|
110
|
+
* `scope` names which open findings this evaluation could have seen (so an unrelated spec's finding
|
|
111
|
+
* is never resolved by a query that did not look at it).
|
|
112
|
+
*/
|
|
113
|
+
function judgeAdvisories(state, current, scope, now, head, recent = [], opts = {}) {
|
|
114
|
+
const issue = opts.issue !== false;
|
|
115
|
+
const out = [];
|
|
116
|
+
const today = recent.filter((r) => day(r.judgedAt) === day(now));
|
|
117
|
+
const seen = new Set(today.map((r) => `${r.id}|${r.outcome}`));
|
|
118
|
+
// `persisted` is a heartbeat, not an event: at most one row per id per day, and never on the day
|
|
119
|
+
// the finding was issued — the same query repeated the same day must add nothing.
|
|
120
|
+
const touchedToday = new Set(today.map((r) => r.id));
|
|
121
|
+
const push = (k, outcome, firstSeen) => {
|
|
122
|
+
if (seen.has(`${k.id}|${outcome}`))
|
|
123
|
+
return;
|
|
124
|
+
if (outcome === 'persisted' && touchedToday.has(k.id))
|
|
125
|
+
return;
|
|
126
|
+
seen.add(`${k.id}|${outcome}`);
|
|
127
|
+
touchedToday.add(k.id);
|
|
128
|
+
out.push({ id: k.id, kind: k.kind, aspec: k.aspec, outcome, firstSeen, judgedAt: now, head });
|
|
129
|
+
};
|
|
130
|
+
const byId = new Map(state.map((s) => [s.id, s]));
|
|
131
|
+
const currentIds = new Set(current.map((c) => c.id));
|
|
132
|
+
for (const c of current) {
|
|
133
|
+
const s = byId.get(c.id);
|
|
134
|
+
if (!s || s.last === 'resolved' || s.last === 'dismissed') {
|
|
135
|
+
if (issue)
|
|
136
|
+
push(c, 'issued', now);
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
push(c, 'persisted', s.firstSeen);
|
|
140
|
+
}
|
|
141
|
+
for (const s of state) {
|
|
142
|
+
if (!scope(s) || currentIds.has(s.id))
|
|
143
|
+
continue;
|
|
144
|
+
if (s.last === 'issued' || s.last === 'persisted')
|
|
145
|
+
push(s, 'resolved', s.firstSeen);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/** Explicit dismissal by the author: only for findings the ledger knows and that are still open. */
|
|
150
|
+
function dismissAdvisories(state, ids, now, head) {
|
|
151
|
+
const byId = new Map(state.map((s) => [s.id, s]));
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const id of ids) {
|
|
154
|
+
const s = byId.get(id);
|
|
155
|
+
if (!s || (s.last !== 'issued' && s.last !== 'persisted'))
|
|
156
|
+
continue;
|
|
157
|
+
out.push({ id: s.id, kind: s.kind, aspec: s.aspec, outcome: 'dismissed', firstSeen: s.firstSeen, judgedAt: now, head });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
exports.ADVISORY_KINDS = ['impact-advisory', 'anchor-density', 'ftt-fulfilment', 'trace-gap', 'kills-unapplicable'];
|
|
162
|
+
/** Per kind: how many findings were ever issued, and how many stand resolved / persisted / dismissed now. */
|
|
163
|
+
function advisoryCensus(rows) {
|
|
164
|
+
const out = Object.fromEntries(exports.ADVISORY_KINDS.map((k) => [k, { issued: 0, resolved: 0, persisted: 0, dismissed: 0 }]));
|
|
165
|
+
for (const s of foldAdvisoryState(rows)) {
|
|
166
|
+
const c = out[s.kind];
|
|
167
|
+
if (!c)
|
|
168
|
+
continue;
|
|
169
|
+
c.issued += 1;
|
|
170
|
+
if (s.last === 'resolved')
|
|
171
|
+
c.resolved += 1;
|
|
172
|
+
else if (s.last === 'persisted')
|
|
173
|
+
c.persisted += 1;
|
|
174
|
+
else if (s.last === 'dismissed')
|
|
175
|
+
c.dismissed += 1;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
// Scanned files carry ABSOLUTE paths (CpgScanner); Files to Touch are repo-relative. Compare in one spelling.
|
|
180
|
+
function relPosix(p, root) {
|
|
181
|
+
const rel = root && path.isAbsolute(p) ? path.relative(root, p) : p;
|
|
182
|
+
return rel.split(path.sep).join('/').replace(/\\/g, '/');
|
|
183
|
+
}
|
|
184
|
+
const FILE_RE = /^advisory-outcomes\.([^.]+)\.jsonl$/;
|
|
185
|
+
function appendAdvisoryOutcomes(root, rows) {
|
|
186
|
+
if (rows.length === 0)
|
|
187
|
+
return true;
|
|
188
|
+
try {
|
|
189
|
+
if (!fs.existsSync(path.join(root, '.ax')))
|
|
190
|
+
return false;
|
|
191
|
+
let replica = 'local';
|
|
192
|
+
try {
|
|
193
|
+
replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
|
|
194
|
+
}
|
|
195
|
+
catch { /* keep the fallback */ }
|
|
196
|
+
const file = path.join(root, '.ax', 'ledger', `advisory-outcomes.${replica}.jsonl`);
|
|
197
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
198
|
+
fs.appendFileSync(file, rows.map((r) => `${JSON.stringify({ ...r, replica })}\n`).join(''));
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function readAdvisoryOutcomes(root) {
|
|
206
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
207
|
+
let names;
|
|
208
|
+
try {
|
|
209
|
+
names = fs.readdirSync(dir).filter((n) => FILE_RE.test(n)).sort();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const n of names) {
|
|
216
|
+
let text;
|
|
217
|
+
try {
|
|
218
|
+
text = fs.readFileSync(path.join(dir, n), 'utf8');
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const line of text.split('\n')) {
|
|
224
|
+
if (!line.trim())
|
|
225
|
+
continue;
|
|
226
|
+
try {
|
|
227
|
+
out.push(JSON.parse(line));
|
|
228
|
+
}
|
|
229
|
+
catch { /* a torn line is not a record */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out.sort((a, b) => (a.judgedAt < b.judgedAt ? -1 : a.judgedAt > b.judgedAt ? 1 : 0));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* One trace-gap finding per unlinked approved A-SPEC that declares at least one SCANNED production
|
|
236
|
+
* file in its Files to Touch — the same population REQ-658 reports per change, taken whole. The
|
|
237
|
+
* payload is the sorted file list, so moving one anchor changes the id (a smaller gap is a new
|
|
238
|
+
* finding, the old one resolved) — honest, if noisy; the census counts findings, not specs.
|
|
239
|
+
*/
|
|
240
|
+
function traceGapAdvisories(specs, scanned, root = '') {
|
|
241
|
+
const scannedPaths = new Set(scanned.map((f) => relPosix(f.path, root)));
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const u of (0, trace_gaps_1.unlinkedApproved)(specs, scanned)) {
|
|
244
|
+
const files = [...new Set((0, ftt_fulfilment_1.fttItems)(u.ftt).map((i) => i.path).filter((p) => scannedPaths.has(p)))].sort();
|
|
245
|
+
if (files.length === 0)
|
|
246
|
+
continue;
|
|
247
|
+
out.push({ id: advisoryId('trace-gap', u.id, { files }), kind: 'trace-gap', aspec: u.id });
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
function currentAdvisoryKeys(aspec, c) {
|
|
252
|
+
const keys = [];
|
|
253
|
+
const ids = {};
|
|
254
|
+
const add = (kind, payload) => { const id = advisoryId(kind, aspec, payload); keys.push({ id, kind, aspec }); ids[kind] = id; };
|
|
255
|
+
if (c.impactAdvisory && c.impactAdvisory.files.length > 0)
|
|
256
|
+
add('impact-advisory', { files: c.impactAdvisory.files.map((f) => f.path) });
|
|
257
|
+
if (c.anchorDensity && c.anchorDensity.length > 0)
|
|
258
|
+
add('anchor-density', { files: c.anchorDensity.map((f) => f.path) });
|
|
259
|
+
if (c.fttFulfilment)
|
|
260
|
+
add('ftt-fulfilment', { missing: c.fttFulfilment.missing.map((m) => m.path), moved: c.fttFulfilment.moved.map((m) => m.path), alternatives: c.fttFulfilment.alternatives.map((m) => m.path) });
|
|
261
|
+
if (c.killsAdvisory && c.killsAdvisory.unapplicable.length > 0)
|
|
262
|
+
add('kills-unapplicable', { unapplicable: c.killsAdvisory.unapplicable.map((u) => ({ where: u.where, reason: u.reason })) });
|
|
263
|
+
if (c.traceGap && c.traceGap.files.length > 0)
|
|
264
|
+
add('trace-gap', { files: [...c.traceGap.files].sort() });
|
|
265
|
+
return { keys, ids };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* One act: read the ledger, judge `current` against it within `scope`, apply explicit dismissals,
|
|
269
|
+
* append, and return what the response should carry. I/O failures leave the response without the
|
|
270
|
+
* fields (the seal or the query is unaffected). Never throws.
|
|
271
|
+
*/
|
|
272
|
+
function recordAdvisoryOutcomes(root, aspec, current, scope, head, dismiss = [], now = new Date().toISOString(), opts = {}) {
|
|
273
|
+
try {
|
|
274
|
+
const rows = readAdvisoryOutcomes(root);
|
|
275
|
+
const state = foldAdvisoryState(rows);
|
|
276
|
+
const judged = judgeAdvisories(state, current, scope, now, head, rows, opts);
|
|
277
|
+
const after = foldAdvisoryState([...rows, ...judged]);
|
|
278
|
+
const dismissed = dismissAdvisories(after, dismiss, now, head);
|
|
279
|
+
const known = new Set(dismissed.map((d) => d.id));
|
|
280
|
+
const dismissUnknown = dismiss.filter((id) => !known.has(id));
|
|
281
|
+
appendAdvisoryOutcomes(root, [...judged, ...dismissed]);
|
|
282
|
+
const history = foldAdvisoryState([...rows, ...judged, ...dismissed]).filter((s) => s.aspec === aspec);
|
|
283
|
+
return { history, dismissUnknown };
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return { history: [], dismissUnknown: dismiss };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** The scanned production files an A-SPEC declares — the trace-gap payload for one spec. */
|
|
290
|
+
function traceGapFiles(spec, scanned, root = '') {
|
|
291
|
+
const scannedPaths = new Set(scanned.map((f) => relPosix(f.path, root)));
|
|
292
|
+
return [...new Set((0, ftt_fulfilment_1.fttItems)(String(spec.sections?.['Files to Touch'] ?? '')).map((i) => i.path).filter((p) => scannedPaths.has(p)))].sort();
|
|
293
|
+
}
|
|
294
|
+
/** The commit a reaction row is judged at; 'nogit' when git cannot say. Never throws. */
|
|
295
|
+
function gitHeadOf(root) {
|
|
296
|
+
try {
|
|
297
|
+
return (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || 'nogit';
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return 'nogit';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The trace-gap finding for ONE spec, status aside: the scanned production files it declares, or
|
|
305
|
+
* null when a scanned file already anchors it or it declares none. Used at the seal (where the
|
|
306
|
+
* spec is approved by the time this runs) and in the preview.
|
|
307
|
+
*/
|
|
308
|
+
function traceGapFor(specId, spec, scanned, root = '') {
|
|
309
|
+
for (const f of scanned)
|
|
310
|
+
if ((f.implementsSpecs ?? []).includes(specId))
|
|
311
|
+
return null;
|
|
312
|
+
const files = traceGapFiles(spec, scanned, root);
|
|
313
|
+
return files.length > 0 ? { files } : null;
|
|
314
|
+
}
|
|
@@ -6,4 +6,6 @@
|
|
|
6
6
|
* a syntax this map does not know returns null and the caller must REFUSE to write — an honest
|
|
7
7
|
* refusal beats a broken file, and beats an anchor the gate cannot read.
|
|
8
8
|
*/
|
|
9
|
+
export declare const INJECTABLE_EXTENSIONS: readonly string[];
|
|
10
|
+
export declare const INJECTABLE_BASENAMES: readonly string[];
|
|
9
11
|
export declare function anchorLineFor(filePath: string, aspecId: string): string | null;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* refusal beats a broken file, and beats an anchor the gate cannot read.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.INJECTABLE_BASENAMES = exports.INJECTABLE_EXTENSIONS = void 0;
|
|
12
13
|
exports.anchorLineFor = anchorLineFor;
|
|
13
14
|
const SLASH = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'java', 'c', 'h', 'cc', 'cpp',
|
|
14
15
|
'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'kts', 'scala', 'dart']);
|
|
@@ -16,6 +17,13 @@ const HASH = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yml', 'yaml', 't
|
|
|
16
17
|
'r', 'jl', 'cmake', 'mk']);
|
|
17
18
|
const CSS = new Set(['css', 'scss', 'less']);
|
|
18
19
|
const HASH_BASENAMES = new Set(['Makefile', 'Dockerfile', 'Rakefile', 'Gemfile']);
|
|
20
|
+
// @implements A-SPEC-655 — the injector's syntax set, exported so the census can read every file
|
|
21
|
+
// the injector may have written. Measured 2026-09-16: this map knew `.sh/.yml/.toml/...` and the
|
|
22
|
+
// indexer's SCANNABLE_EXTENSIONS did not, so `spec_remediate` planted anchors the graph could
|
|
23
|
+
// never see (scripts/ci-local.sh → A-SPEC-530.1). `rtm/file-anchors.ts` reads the difference and
|
|
24
|
+
// link-census.test.ts pins INJECTABLE ⊆ SCANNABLE ∪ FILE_ANCHOR over the whole set.
|
|
25
|
+
exports.INJECTABLE_EXTENSIONS = [...new Set([...SLASH, ...HASH, ...CSS])].sort();
|
|
26
|
+
exports.INJECTABLE_BASENAMES = [...HASH_BASENAMES].sort();
|
|
19
27
|
function anchorLineFor(filePath, aspecId) {
|
|
20
28
|
const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
|
|
21
29
|
if (HASH_BASENAMES.has(basename))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Extensions the injector writes that the indexer does not parse — the census reads these. */
|
|
2
|
+
export declare const FILE_ANCHOR_EXTENSIONS: readonly string[];
|
|
3
|
+
/** True for a PROJECT-RELATIVE POSIX path the file-anchor scanner reads. */
|
|
4
|
+
export declare function isFileAnchorPath(rel: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Repo-relative POSIX path → A-SPEC ids, for every file `isFileAnchorPath` admits. Same walk and
|
|
7
|
+
* resilience as `scanTestAnchors`: unreadable files are skipped, never reported as anchor-less.
|
|
8
|
+
*/
|
|
9
|
+
export declare function scanFileAnchors(root: string): Record<string, string[]>;
|
|
@@ -0,0 +1,128 @@
|
|
|
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.FILE_ANCHOR_EXTENSIONS = void 0;
|
|
37
|
+
exports.isFileAnchorPath = isFileAnchorPath;
|
|
38
|
+
exports.scanFileAnchors = scanFileAnchors;
|
|
39
|
+
// @implements A-SPEC-655
|
|
40
|
+
/**
|
|
41
|
+
* Anchors in files the injector can write but the indexer never parses.
|
|
42
|
+
*
|
|
43
|
+
* `anchor-comment.ts` knows the comment syntax of `.sh/.yml/.toml/.rb/...` and `spec_remediate`
|
|
44
|
+
* injects `# @implements` there; `cpg-scanner.ts` ingests 19 AST extensions and none of those.
|
|
45
|
+
* Measured 2026-09-16: `scripts/ci-local.sh` carries a hash-comment anchor line naming
|
|
46
|
+
* A-SPEC-530.1 that no graph edge will ever reflect. (The id is deliberately not written here in
|
|
47
|
+
* anchor form: the scanner reads the marker anywhere in a file, and this module's first attempt
|
|
48
|
+
* quoted the line verbatim — which made THIS file the implementer of A-SPEC-530.1.) This scanner reads exactly the injector's set minus the indexer's set —
|
|
49
|
+
* off-graph, like `scanTestAnchors` — so the census can say "anchored in a file the graph does
|
|
50
|
+
* not parse" instead of "no trace". The set difference is computed, not copied: when either list
|
|
51
|
+
* moves, `FILE_ANCHOR_EXTENSIONS` follows, and link-census.test.ts pins the inclusion invariant
|
|
52
|
+
* INJECTABLE ⊆ SCANNABLE ∪ FILE_ANCHOR over the whole set.
|
|
53
|
+
*
|
|
54
|
+
* NOT a graph input. Test files stay out (isTestFile), `.ax/` stays out (spec documents mention
|
|
55
|
+
* the marker in prose), and nothing here creates a node or an edge.
|
|
56
|
+
*/
|
|
57
|
+
const fs = __importStar(require("node:fs"));
|
|
58
|
+
const path = __importStar(require("node:path"));
|
|
59
|
+
const anchor_comment_1 = require("./anchor-comment");
|
|
60
|
+
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
61
|
+
const test_scope_1 = require("./test-scope");
|
|
62
|
+
const SCANNABLE = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS.map((e) => e.replace(/^\./, '')));
|
|
63
|
+
/** Extensions the injector writes that the indexer does not parse — the census reads these. */
|
|
64
|
+
exports.FILE_ANCHOR_EXTENSIONS = anchor_comment_1.INJECTABLE_EXTENSIONS.filter((e) => !SCANNABLE.has(e));
|
|
65
|
+
const FILE_ANCHOR = new Set(exports.FILE_ANCHOR_EXTENSIONS);
|
|
66
|
+
const BASENAMES = new Set(anchor_comment_1.INJECTABLE_BASENAMES);
|
|
67
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
|
|
68
|
+
// `isTestFile` is language-scoped on purpose (an unscoped `tests/` rule once swallowed
|
|
69
|
+
// `src/holmes/spec/**`); a shell script or workflow under a test directory has no such hazard and is
|
|
70
|
+
// test scaffolding, so the directory rule applies here in full.
|
|
71
|
+
const TEST_DIRS = new Set(['test', 'tests', '__tests__']);
|
|
72
|
+
/** True for a PROJECT-RELATIVE POSIX path the file-anchor scanner reads. */
|
|
73
|
+
function isFileAnchorPath(rel) {
|
|
74
|
+
const segs = rel.split('/');
|
|
75
|
+
if (rel.startsWith('.ax/') || segs.some((seg) => SKIP_DIRS.has(seg)))
|
|
76
|
+
return false;
|
|
77
|
+
if ((0, cpg_scanner_1.isTestFile)(rel) || segs.slice(0, -1).some((seg) => TEST_DIRS.has(seg)))
|
|
78
|
+
return false;
|
|
79
|
+
const base = rel.slice(rel.lastIndexOf('/') + 1);
|
|
80
|
+
if (BASENAMES.has(base))
|
|
81
|
+
return true;
|
|
82
|
+
const dot = base.lastIndexOf('.');
|
|
83
|
+
if (dot <= 0)
|
|
84
|
+
return false;
|
|
85
|
+
return FILE_ANCHOR.has(base.slice(dot + 1).toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Repo-relative POSIX path → A-SPEC ids, for every file `isFileAnchorPath` admits. Same walk and
|
|
89
|
+
* resilience as `scanTestAnchors`: unreadable files are skipped, never reported as anchor-less.
|
|
90
|
+
*/
|
|
91
|
+
function scanFileAnchors(root) {
|
|
92
|
+
const out = {};
|
|
93
|
+
const walk = (dir) => {
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const e of entries) {
|
|
102
|
+
if (SKIP_DIRS.has(e.name))
|
|
103
|
+
continue;
|
|
104
|
+
const abs = path.join(dir, e.name);
|
|
105
|
+
if (e.isDirectory()) {
|
|
106
|
+
walk(abs);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!e.isFile())
|
|
110
|
+
continue;
|
|
111
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
112
|
+
if (!isFileAnchorPath(rel))
|
|
113
|
+
continue;
|
|
114
|
+
let text;
|
|
115
|
+
try {
|
|
116
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const ids = (0, test_scope_1.extractAnchors)(text);
|
|
122
|
+
if (ids.length)
|
|
123
|
+
out[rel] = ids;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk(root);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface FttItem {
|
|
2
|
+
path: string;
|
|
3
|
+
line: string;
|
|
4
|
+
isNew: boolean;
|
|
5
|
+
alternative: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface FttFulfilment {
|
|
8
|
+
/** Items examined — so "no finding" can be told from "nothing to examine". */
|
|
9
|
+
declared: number;
|
|
10
|
+
missing: Array<{
|
|
11
|
+
path: string;
|
|
12
|
+
isNew: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
moved: Array<{
|
|
15
|
+
path: string;
|
|
16
|
+
foundAt: string[];
|
|
17
|
+
}>;
|
|
18
|
+
alternatives: Array<{
|
|
19
|
+
path: string;
|
|
20
|
+
line: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface FulfilmentRecord {
|
|
24
|
+
aspec: string;
|
|
25
|
+
declared: number;
|
|
26
|
+
missing: string[];
|
|
27
|
+
moved: string[];
|
|
28
|
+
ts: string;
|
|
29
|
+
replica?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
|
|
33
|
+
* root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
|
|
34
|
+
* their fulfilment is not a single file's existence.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fttItems(fttText: string): FttItem[];
|
|
37
|
+
export declare function fttFulfilment(fttText: string, exists: (rel: string) => boolean, locate: (basename: string) => string[]): FttFulfilment | null;
|
|
38
|
+
/** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
|
|
39
|
+
export declare function locateByBasename(root: string): (basename: string) => string[];
|
|
40
|
+
/** The observation ledger — repo-relative paths, a spec id, integers and a timestamp; nothing else. */
|
|
41
|
+
export declare function appendFttFulfilment(root: string, rec: FulfilmentRecord): boolean;
|
|
42
|
+
export declare function readFttFulfilments(root: string): FulfilmentRecord[];
|