@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,195 @@
|
|
|
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.fttItems = fttItems;
|
|
37
|
+
exports.fttFulfilment = fttFulfilment;
|
|
38
|
+
exports.locateByBasename = locateByBasename;
|
|
39
|
+
exports.appendFttFulfilment = appendFttFulfilment;
|
|
40
|
+
exports.readFttFulfilments = readFttFulfilments;
|
|
41
|
+
// @implements A-SPEC-656
|
|
42
|
+
/**
|
|
43
|
+
* Files-to-Touch fulfilment — the paths a sealed A-SPEC promised and never made.
|
|
44
|
+
*
|
|
45
|
+
* `declaredImpactGap` says what the declaration MISSED (from the call graph); this says what the
|
|
46
|
+
* declaration PROMISED and the tree does not hold (from the filesystem). Measured 2026-09-16 on
|
|
47
|
+
* this repository: four promised paths across three approved specs never existed, and nothing
|
|
48
|
+
* named them — every gate reads the other direction (code → declaration). The same measurement
|
|
49
|
+
* found the three false-positive shapes a naive existence check would raise, so each has its own
|
|
50
|
+
* bucket: an item written as an alternative ("또는" / "or"), a basename that lives elsewhere
|
|
51
|
+
* (location drift), and prose that is not an item at all (already not a token under REQ-654).
|
|
52
|
+
*
|
|
53
|
+
* ADVISORY, NEVER VERDICT: nothing here can change ok/refuse/grant. NO GUESSING: a failed read
|
|
54
|
+
* propagates to the caller, which drops the field rather than inventing a finding.
|
|
55
|
+
*/
|
|
56
|
+
const fs = __importStar(require("node:fs"));
|
|
57
|
+
const path = __importStar(require("node:path"));
|
|
58
|
+
const scope_judgment_1 = require("../guardrail/scope-judgment");
|
|
59
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
60
|
+
const ITEM_RE = /^\s*[-*]\s+(.*)$/;
|
|
61
|
+
const NEW_RE = /\((신규|new)\)/i;
|
|
62
|
+
const ALT_RE = /또는|\bor\b/;
|
|
63
|
+
const SLASHED = /^[\w@.-]+(?:\/[\w@.-]+)+$/;
|
|
64
|
+
const HAS_EXT = /\.[A-Za-z][A-Za-z0-9]*$/;
|
|
65
|
+
/**
|
|
66
|
+
* List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
|
|
67
|
+
* root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
|
|
68
|
+
* their fulfilment is not a single file's existence.
|
|
69
|
+
*/
|
|
70
|
+
function fttItems(fttText) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const raw of String(fttText ?? '').replace(/\\/g, '/').split('\n')) {
|
|
73
|
+
const m = ITEM_RE.exec(raw);
|
|
74
|
+
if (!m)
|
|
75
|
+
continue;
|
|
76
|
+
const line = m[1].trim();
|
|
77
|
+
const word = (line.split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '').replace(/^`?([^`]*?)`?\(/, '$1(');
|
|
78
|
+
const token = word.replace(/\(.*$/, ''); // `src/b.ts`(new) → src/b.ts
|
|
79
|
+
if (!token || token.includes('*') || !HAS_EXT.test(token))
|
|
80
|
+
continue;
|
|
81
|
+
if (!(SLASHED.test(token) || (0, scope_judgment_1.isRootFileToken)(token)))
|
|
82
|
+
continue;
|
|
83
|
+
out.push({ path: token, line, isNew: NEW_RE.test(line), alternative: ALT_RE.test(line) });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function fttFulfilment(fttText, exists, locate) {
|
|
88
|
+
const items = fttItems(fttText);
|
|
89
|
+
if (items.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const missing = [];
|
|
92
|
+
const moved = [];
|
|
93
|
+
const alternatives = [];
|
|
94
|
+
for (const it of items) {
|
|
95
|
+
if (it.alternative) {
|
|
96
|
+
alternatives.push({ path: it.path, line: it.line });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (exists(it.path))
|
|
100
|
+
continue;
|
|
101
|
+
const base = it.path.slice(it.path.lastIndexOf('/') + 1);
|
|
102
|
+
const foundAt = [...new Set(locate(base))].filter((p) => p !== it.path).sort();
|
|
103
|
+
if (foundAt.length > 0)
|
|
104
|
+
moved.push({ path: it.path, foundAt });
|
|
105
|
+
else
|
|
106
|
+
missing.push({ path: it.path, isNew: it.isNew });
|
|
107
|
+
}
|
|
108
|
+
if (missing.length === 0 && moved.length === 0 && alternatives.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
return { declared: items.length, missing, moved, alternatives };
|
|
111
|
+
}
|
|
112
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
|
|
113
|
+
/** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
|
|
114
|
+
function locateByBasename(root) {
|
|
115
|
+
const byBase = new Map();
|
|
116
|
+
const walk = (dir) => {
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const e of entries) {
|
|
125
|
+
if (SKIP_DIRS.has(e.name))
|
|
126
|
+
continue;
|
|
127
|
+
const abs = path.join(dir, e.name);
|
|
128
|
+
if (e.isDirectory()) {
|
|
129
|
+
walk(abs);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!e.isFile())
|
|
133
|
+
continue;
|
|
134
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
135
|
+
const list = byBase.get(e.name) ?? [];
|
|
136
|
+
list.push(rel);
|
|
137
|
+
byBase.set(e.name, list);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
walk(root);
|
|
141
|
+
return (basename) => [...(byBase.get(basename) ?? [])].sort();
|
|
142
|
+
}
|
|
143
|
+
const LEDGER_RE = /^ftt-fulfilment\.([^.]+)\.jsonl$/;
|
|
144
|
+
/** The observation ledger — repo-relative paths, a spec id, integers and a timestamp; nothing else. */
|
|
145
|
+
function appendFttFulfilment(root, rec) {
|
|
146
|
+
try {
|
|
147
|
+
if (!fs.existsSync(path.join(root, '.ax')))
|
|
148
|
+
return false;
|
|
149
|
+
let replica = 'local';
|
|
150
|
+
try {
|
|
151
|
+
replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
|
|
152
|
+
}
|
|
153
|
+
catch { /* keep the fallback */ }
|
|
154
|
+
const file = path.join(root, '.ax', 'ledger', `ftt-fulfilment.${replica}.jsonl`);
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
156
|
+
const line = { aspec: rec.aspec, declared: rec.declared, missing: [...rec.missing], moved: [...rec.moved], ts: rec.ts, replica };
|
|
157
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function readFttFulfilments(root) {
|
|
165
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
166
|
+
let names;
|
|
167
|
+
try {
|
|
168
|
+
names = fs.readdirSync(dir).filter((n) => LEDGER_RE.test(n)).sort();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
let text;
|
|
176
|
+
try {
|
|
177
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
for (const raw of text.split('\n')) {
|
|
183
|
+
const s = raw.trim();
|
|
184
|
+
if (!s)
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
const r = JSON.parse(s);
|
|
188
|
+
if (r && typeof r.aspec === 'string' && Array.isArray(r.missing) && Array.isArray(r.moved) && typeof r.ts === 'string')
|
|
189
|
+
out.push(r);
|
|
190
|
+
}
|
|
191
|
+
catch { /* a corrupt line never breaks the read */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface KnownDefect {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
reason: string;
|
|
5
|
+
expires: string;
|
|
6
|
+
}
|
|
7
|
+
export interface MalformedMarker {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
text: string;
|
|
11
|
+
why: 'no-reason' | 'no-expires' | 'bad-date';
|
|
12
|
+
}
|
|
13
|
+
export interface KnownDefectJudgement {
|
|
14
|
+
unexpired: KnownDefect[];
|
|
15
|
+
expired: KnownDefect[];
|
|
16
|
+
malformed: MalformedMarker[];
|
|
17
|
+
}
|
|
18
|
+
export declare function knownDefectsIn(source: string, file: string): {
|
|
19
|
+
markers: KnownDefect[];
|
|
20
|
+
malformed: MalformedMarker[];
|
|
21
|
+
};
|
|
22
|
+
/** Expired when the expiry day's UTC midnight is at or before `now` — the day itself counts as expired. */
|
|
23
|
+
export declare function judgeKnownDefects(found: {
|
|
24
|
+
markers: KnownDefect[];
|
|
25
|
+
malformed: MalformedMarker[];
|
|
26
|
+
}, now: Date): KnownDefectJudgement;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.knownDefectsIn = knownDefectsIn;
|
|
4
|
+
exports.judgeKnownDefects = judgeKnownDefects;
|
|
5
|
+
// @implements A-SPEC-660
|
|
6
|
+
/**
|
|
7
|
+
* `@known-defect(<reason>, expires=YYYY-MM-DD)` — the machine-readable marker for an assertion that
|
|
8
|
+
* pins a KNOWN DEFECT as the expected value.
|
|
9
|
+
*
|
|
10
|
+
* jarvis 2026-09-08: a query that could only ever return 0 was worked around instead of fixed, the
|
|
11
|
+
* workaround was pinned by a test (`assert raw == []`), and to every gate that test was
|
|
12
|
+
* indistinguishable from a healthy one — green, anchored, covered. The docstring said "trap"; only
|
|
13
|
+
* people read docstrings, and the next person repeated the misjudgement. Three precedents make the
|
|
14
|
+
* shape: posixOnly(reason) refuses a reason-less skip, countTestCases folds a marker head into
|
|
15
|
+
* ART-4's evidence, sessionAutonomyActive judges `expires` fail-closed.
|
|
16
|
+
*
|
|
17
|
+
* Pure. Markers are read from COMMENT lines only, after string literals are removed — the ART-4
|
|
18
|
+
* counter's lesson, so a fixture string never becomes a marker. A marker that cannot be read is
|
|
19
|
+
* returned as `malformed`, never dropped: a typo that parses to nothing reads as "the rule passed".
|
|
20
|
+
* The clock is injected; nothing here reads process time.
|
|
21
|
+
*/
|
|
22
|
+
const anchor_ids_1 = require("./anchor-ids");
|
|
23
|
+
// A comment line (`//`, `/*`, `*`, `#`) carrying the marker; the body runs to the closing paren.
|
|
24
|
+
const MARKER_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*|#)[^\n]*?@known-defect\(([^)]*)\)/;
|
|
25
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
26
|
+
/** The calendar date's UTC midnight, or null when the string is not a real date. */
|
|
27
|
+
function utcMidnightOf(date) {
|
|
28
|
+
const m = DATE_RE.exec(date);
|
|
29
|
+
if (!m)
|
|
30
|
+
return null;
|
|
31
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
32
|
+
const t = Date.UTC(y, mo - 1, d);
|
|
33
|
+
const back = new Date(t);
|
|
34
|
+
return back.getUTCFullYear() === y && back.getUTCMonth() === mo - 1 && back.getUTCDate() === d ? t : null;
|
|
35
|
+
}
|
|
36
|
+
function knownDefectsIn(source, file) {
|
|
37
|
+
const markers = [];
|
|
38
|
+
const malformed = [];
|
|
39
|
+
const lines = (0, anchor_ids_1.stripStringLiterals)(String(source ?? '')).split('\n');
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const m = MARKER_LINE_RE.exec(lines[i]);
|
|
42
|
+
if (!m)
|
|
43
|
+
continue;
|
|
44
|
+
const body = m[1];
|
|
45
|
+
const text = `@known-defect(${body})`;
|
|
46
|
+
const at = body.lastIndexOf(', expires=');
|
|
47
|
+
if (at === -1) {
|
|
48
|
+
malformed.push({ file, line: i + 1, text, why: 'no-expires' });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const reason = body.slice(0, at).trim();
|
|
52
|
+
const expires = body.slice(at + ', expires='.length).trim();
|
|
53
|
+
if (reason === '') {
|
|
54
|
+
malformed.push({ file, line: i + 1, text, why: 'no-reason' });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (utcMidnightOf(expires) === null) {
|
|
58
|
+
malformed.push({ file, line: i + 1, text, why: 'bad-date' });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
markers.push({ file, line: i + 1, reason, expires });
|
|
62
|
+
}
|
|
63
|
+
return { markers, malformed };
|
|
64
|
+
}
|
|
65
|
+
/** Expired when the expiry day's UTC midnight is at or before `now` — the day itself counts as expired. */
|
|
66
|
+
function judgeKnownDefects(found, now) {
|
|
67
|
+
const unexpired = [];
|
|
68
|
+
const expired = [];
|
|
69
|
+
for (const k of found.markers) {
|
|
70
|
+
const t = utcMidnightOf(k.expires);
|
|
71
|
+
if (t === null || t <= now.getTime())
|
|
72
|
+
expired.push(k);
|
|
73
|
+
else
|
|
74
|
+
unexpired.push(k);
|
|
75
|
+
}
|
|
76
|
+
return { unexpired, expired, malformed: [...found.malformed] };
|
|
77
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RTM link census — which approved A-SPECs the code graph cannot see, and why.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-16 on this repository: the dashboard census said coveragePct 100 while only
|
|
5
|
+
* 558/630 approved A-SPECs (88.6%) carried an `implements` edge; the other 72 were anchored only
|
|
6
|
+
* in test files the scanner deliberately excludes. "Unknown" and "absent" were the same number.
|
|
7
|
+
* The census here keeps the graph's contracts (tests and non-AST files stay OUT of the graph —
|
|
8
|
+
* cpg-scanner.ts:141-147 is a sealed decision) and classifies the gap instead:
|
|
9
|
+
*
|
|
10
|
+
* test-only anchored only in test files (a characterization / docs / CI slice — legitimate)
|
|
11
|
+
* file-anchor anchored only in files the injector can write but the indexer never parses
|
|
12
|
+
* test-and-file both of the above
|
|
13
|
+
* weak-anchor anchored nowhere, but a file inside the spec's own Files to Touch names the id
|
|
14
|
+
* none no trace at all
|
|
15
|
+
*
|
|
16
|
+
* The weak-anchor predicate is deliberately NARROW. The backlog card that proposed it ("any
|
|
17
|
+
* `A-SPEC-N` mention in a comment without a matching @implements") scores 817 false positives and
|
|
18
|
+
* 0 true positives on this repository; the narrowed form scores 0 and 0. Pure: every reader is
|
|
19
|
+
* injected, so the classifier is testable without a filesystem.
|
|
20
|
+
*/
|
|
21
|
+
export type UnlinkedReason = 'test-only' | 'file-anchor' | 'test-and-file' | 'weak-anchor' | 'none';
|
|
22
|
+
export interface UnlinkedSpec {
|
|
23
|
+
id: string;
|
|
24
|
+
reason: UnlinkedReason;
|
|
25
|
+
/** Test and file-anchor paths that carry the id, sorted. Empty for weak-anchor / none. */
|
|
26
|
+
anchoredIn: string[];
|
|
27
|
+
/** Files-to-Touch paths whose text names the id — weak-anchor only. */
|
|
28
|
+
mentionedIn: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface LinkCensus {
|
|
31
|
+
total: number;
|
|
32
|
+
codeLinked: number;
|
|
33
|
+
/** One decimal, never NaN. */
|
|
34
|
+
codeLinkedPct: number;
|
|
35
|
+
/** Sorted by id. */
|
|
36
|
+
unlinked: UnlinkedSpec[];
|
|
37
|
+
/** Always carries all five keys. */
|
|
38
|
+
byReason: Record<UnlinkedReason, number>;
|
|
39
|
+
}
|
|
40
|
+
export interface LinkCensusInput {
|
|
41
|
+
approvedIds: string[];
|
|
42
|
+
/** Ids anchored by production (scanned) source — the ids that own an `implements` edge. */
|
|
43
|
+
implemented: Iterable<string>;
|
|
44
|
+
/** `scanTestAnchors` shape: repo-relative POSIX path → ids. */
|
|
45
|
+
testAnchors: Record<string, string[]>;
|
|
46
|
+
/** `scanFileAnchors` shape: repo-relative POSIX path → ids. */
|
|
47
|
+
fileAnchors: Record<string, string[]>;
|
|
48
|
+
/** id → Files-to-Touch paths whose text names it (see `weakMentionsFor`). */
|
|
49
|
+
weakMentions: Record<string, string[]>;
|
|
50
|
+
}
|
|
51
|
+
export declare const UNLINKED_REASONS: readonly UnlinkedReason[];
|
|
52
|
+
export declare function emptyByReason(): Record<UnlinkedReason, number>;
|
|
53
|
+
/** Percentage with one decimal; a zero denominator reads as 0, never NaN. */
|
|
54
|
+
export declare function pct1(num: number, den: number): number;
|
|
55
|
+
/**
|
|
56
|
+
* Files-to-Touch paths whose text names EXACTLY `id`. `A-SPEC-12` does not match `A-SPEC-120` or
|
|
57
|
+
* `A-SPEC-12.1`. A reader returning null (unreadable, absent) contributes nothing — an unreadable
|
|
58
|
+
* file is neither a mention nor evidence of its absence.
|
|
59
|
+
*/
|
|
60
|
+
export declare function weakMentionsFor(id: string, fttFiles: string[], readText: (rel: string) => string | null): string[];
|
|
61
|
+
export declare function linkCensus(input: LinkCensusInput): LinkCensus;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-655
|
|
3
|
+
/**
|
|
4
|
+
* RTM link census — which approved A-SPECs the code graph cannot see, and why.
|
|
5
|
+
*
|
|
6
|
+
* Measured 2026-09-16 on this repository: the dashboard census said coveragePct 100 while only
|
|
7
|
+
* 558/630 approved A-SPECs (88.6%) carried an `implements` edge; the other 72 were anchored only
|
|
8
|
+
* in test files the scanner deliberately excludes. "Unknown" and "absent" were the same number.
|
|
9
|
+
* The census here keeps the graph's contracts (tests and non-AST files stay OUT of the graph —
|
|
10
|
+
* cpg-scanner.ts:141-147 is a sealed decision) and classifies the gap instead:
|
|
11
|
+
*
|
|
12
|
+
* test-only anchored only in test files (a characterization / docs / CI slice — legitimate)
|
|
13
|
+
* file-anchor anchored only in files the injector can write but the indexer never parses
|
|
14
|
+
* test-and-file both of the above
|
|
15
|
+
* weak-anchor anchored nowhere, but a file inside the spec's own Files to Touch names the id
|
|
16
|
+
* none no trace at all
|
|
17
|
+
*
|
|
18
|
+
* The weak-anchor predicate is deliberately NARROW. The backlog card that proposed it ("any
|
|
19
|
+
* `A-SPEC-N` mention in a comment without a matching @implements") scores 817 false positives and
|
|
20
|
+
* 0 true positives on this repository; the narrowed form scores 0 and 0. Pure: every reader is
|
|
21
|
+
* injected, so the classifier is testable without a filesystem.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.UNLINKED_REASONS = void 0;
|
|
25
|
+
exports.emptyByReason = emptyByReason;
|
|
26
|
+
exports.pct1 = pct1;
|
|
27
|
+
exports.weakMentionsFor = weakMentionsFor;
|
|
28
|
+
exports.linkCensus = linkCensus;
|
|
29
|
+
exports.UNLINKED_REASONS = ['test-only', 'file-anchor', 'test-and-file', 'weak-anchor', 'none'];
|
|
30
|
+
function emptyByReason() {
|
|
31
|
+
return { 'test-only': 0, 'file-anchor': 0, 'test-and-file': 0, 'weak-anchor': 0, none: 0 };
|
|
32
|
+
}
|
|
33
|
+
/** Percentage with one decimal; a zero denominator reads as 0, never NaN. */
|
|
34
|
+
function pct1(num, den) {
|
|
35
|
+
return den > 0 ? Math.round((num / den) * 1000) / 10 : 0;
|
|
36
|
+
}
|
|
37
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
/**
|
|
39
|
+
* Files-to-Touch paths whose text names EXACTLY `id`. `A-SPEC-12` does not match `A-SPEC-120` or
|
|
40
|
+
* `A-SPEC-12.1`. A reader returning null (unreadable, absent) contributes nothing — an unreadable
|
|
41
|
+
* file is neither a mention nor evidence of its absence.
|
|
42
|
+
*/
|
|
43
|
+
function weakMentionsFor(id, fttFiles, readText) {
|
|
44
|
+
const re = new RegExp(String.raw `(?<![\w.-])${escapeRe(id)}(?!\w|\.\d)`);
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const rel of fttFiles) {
|
|
47
|
+
const text = readText(rel);
|
|
48
|
+
if (text !== null && text !== undefined && re.test(text))
|
|
49
|
+
out.push(rel);
|
|
50
|
+
}
|
|
51
|
+
return [...new Set(out)].sort();
|
|
52
|
+
}
|
|
53
|
+
function invert(map) {
|
|
54
|
+
const byId = new Map();
|
|
55
|
+
for (const [file, ids] of Object.entries(map ?? {})) {
|
|
56
|
+
for (const id of ids ?? []) {
|
|
57
|
+
const list = byId.get(id) ?? [];
|
|
58
|
+
if (!list.includes(file))
|
|
59
|
+
list.push(file);
|
|
60
|
+
byId.set(id, list);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return byId;
|
|
64
|
+
}
|
|
65
|
+
function linkCensus(input) {
|
|
66
|
+
const implemented = new Set(input.implemented);
|
|
67
|
+
const tests = invert(input.testAnchors);
|
|
68
|
+
const files = invert(input.fileAnchors);
|
|
69
|
+
const byReason = emptyByReason();
|
|
70
|
+
const unlinked = [];
|
|
71
|
+
const ids = [...new Set(input.approvedIds)].sort();
|
|
72
|
+
let codeLinked = 0;
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
if (implemented.has(id)) {
|
|
75
|
+
codeLinked++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const t = tests.get(id) ?? [];
|
|
79
|
+
const f = files.get(id) ?? [];
|
|
80
|
+
const mentions = t.length === 0 && f.length === 0 ? [...(input.weakMentions?.[id] ?? [])].sort() : [];
|
|
81
|
+
const reason = t.length && f.length ? 'test-and-file'
|
|
82
|
+
: t.length ? 'test-only'
|
|
83
|
+
: f.length ? 'file-anchor'
|
|
84
|
+
: mentions.length ? 'weak-anchor'
|
|
85
|
+
: 'none';
|
|
86
|
+
byReason[reason]++;
|
|
87
|
+
unlinked.push({ id, reason, anchoredIn: [...t, ...f].sort(), mentionedIn: mentions });
|
|
88
|
+
}
|
|
89
|
+
return { total: ids.length, codeLinked, codeLinkedPct: pct1(codeLinked, ids.length), unlinked, byReason };
|
|
90
|
+
}
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.RtmGraph = void 0;
|
|
7
|
+
// @implements A-SPEC-588
|
|
7
8
|
// @implements A-SPEC-293
|
|
8
9
|
// @implements A-SPEC-644
|
|
9
10
|
// @implements A-SPEC-289
|
|
@@ -38,6 +38,7 @@ exports.runTaintBenchmarkAsync = runTaintBenchmarkAsync;
|
|
|
38
38
|
exports.compareEngines = compareEngines;
|
|
39
39
|
exports.runTaintBenchmark = runTaintBenchmark;
|
|
40
40
|
exports.taintOnRealCorpus = taintOnRealCorpus;
|
|
41
|
+
// @implements A-SPEC-355
|
|
41
42
|
// @implements A-SPEC-354
|
|
42
43
|
const language_parser_1 = require("../cpg/language-parser");
|
|
43
44
|
const dataflow_taint_1 = require("./dataflow-taint");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface UnlinkedSpec {
|
|
2
|
+
id: string;
|
|
3
|
+
ftt: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TraceGap {
|
|
6
|
+
id: string;
|
|
7
|
+
file: string;
|
|
8
|
+
summary?: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** Approved A-SPECs no scanned (production) file anchors, with their Files-to-Touch text; sorted by id. */
|
|
11
|
+
export declare function unlinkedApproved(specs: ReadonlyArray<{
|
|
12
|
+
id: string;
|
|
13
|
+
type: string;
|
|
14
|
+
status: string;
|
|
15
|
+
sections?: Record<string, string>;
|
|
16
|
+
}>, scanned: ReadonlyArray<{
|
|
17
|
+
implementsSpecs?: string[];
|
|
18
|
+
}>): UnlinkedSpec[];
|
|
19
|
+
/** (id, file) for every changed file an unlinked spec declares; sorted by id then file; no duplicates. */
|
|
20
|
+
export declare function traceGaps(changedFiles: string[], unlinked: UnlinkedSpec[], summaryOf?: (id: string) => string | null): TraceGap[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unlinkedApproved = unlinkedApproved;
|
|
4
|
+
exports.traceGaps = traceGaps;
|
|
5
|
+
// @implements A-SPEC-658
|
|
6
|
+
/**
|
|
7
|
+
* Trace gaps — the approved A-SPECs the graph cannot see when a file they declare changes.
|
|
8
|
+
*
|
|
9
|
+
* `rtm_impact` walks `implements` edges, and an approved A-SPEC that no production source anchors
|
|
10
|
+
* has none: it can never appear in any impact set, however directly the change concerns it. That
|
|
11
|
+
* is not "no impact", it is "cannot see" — and until now the two had the same shape (silence).
|
|
12
|
+
* Measured 2026-09-17 on this repository: 24 of the 72 unlinked approved specs (REQ-655's census)
|
|
13
|
+
* declare 34 scanned production files in their Files to Touch — change `hooks/pre-tool-use.ts`
|
|
14
|
+
* today and A-SPEC-202 / A-SPEC-250 stay unmentioned. The jarvis incident was the same shape
|
|
15
|
+
* (`_fmt_at` changed, REQ-347 unreported).
|
|
16
|
+
*
|
|
17
|
+
* Pure: the population is the same one REQ-655 counts (approved A-SPEC minus scanned anchors), the
|
|
18
|
+
* declared files come from REQ-656's item parser (REQ-654's token rule, globs excluded), and the
|
|
19
|
+
* result is an intersection. Information for `rtm_impact`, a `widen` reason for the impact gate;
|
|
20
|
+
* never a verdict on its own.
|
|
21
|
+
*/
|
|
22
|
+
const ftt_fulfilment_1 = require("./ftt-fulfilment");
|
|
23
|
+
/** Approved A-SPECs no scanned (production) file anchors, with their Files-to-Touch text; sorted by id. */
|
|
24
|
+
function unlinkedApproved(specs, scanned) {
|
|
25
|
+
const anchored = new Set();
|
|
26
|
+
for (const f of scanned)
|
|
27
|
+
for (const id of f.implementsSpecs ?? [])
|
|
28
|
+
anchored.add(id);
|
|
29
|
+
return specs
|
|
30
|
+
.filter((s) => s.type === 'A-SPEC' && s.status === 'approved' && !anchored.has(s.id))
|
|
31
|
+
.map((s) => ({ id: s.id, ftt: String(s.sections?.['Files to Touch'] ?? '') }))
|
|
32
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
33
|
+
}
|
|
34
|
+
/** (id, file) for every changed file an unlinked spec declares; sorted by id then file; no duplicates. */
|
|
35
|
+
function traceGaps(changedFiles, unlinked, summaryOf) {
|
|
36
|
+
const files = new Set(changedFiles.map((p) => p.replace(/\\/g, '/')));
|
|
37
|
+
if (files.size === 0)
|
|
38
|
+
return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const u of unlinked) {
|
|
42
|
+
for (const item of (0, ftt_fulfilment_1.fttItems)(u.ftt)) {
|
|
43
|
+
if (!files.has(item.path))
|
|
44
|
+
continue;
|
|
45
|
+
const key = `${u.id} ${item.path}`;
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
const gap = { id: u.id, file: item.path };
|
|
50
|
+
if (summaryOf) {
|
|
51
|
+
let s = null;
|
|
52
|
+
try {
|
|
53
|
+
s = summaryOf(`SPEC:${u.id}`);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
s = null;
|
|
57
|
+
}
|
|
58
|
+
gap.summary = s;
|
|
59
|
+
}
|
|
60
|
+
out.push(gap);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
64
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UnlinkedReason } from '../rtm/link-census';
|
|
1
2
|
/** Honesty summary of what a launched dashboard shows — derived from the endpoints it serves. */
|
|
2
3
|
export interface DashboardCensus {
|
|
3
4
|
reqCount: number;
|
|
@@ -13,6 +14,25 @@ export interface DashboardCensus {
|
|
|
13
14
|
* coveragePct never divides by zero.
|
|
14
15
|
*/
|
|
15
16
|
export declare function dashboardCensus(rtm: any, heatmap: any): DashboardCensus;
|
|
17
|
+
/** The honesty census plus the code-link axis. `dashboardCensus` above stays byte-identical (pinned). */
|
|
18
|
+
export interface DashboardCensusExtended extends DashboardCensus {
|
|
19
|
+
codeLinkedPct: number;
|
|
20
|
+
codeLinkedCount: number;
|
|
21
|
+
unlinkedCount: number;
|
|
22
|
+
unlinkedByReason: Record<UnlinkedReason, number>;
|
|
23
|
+
excluded: {
|
|
24
|
+
total: number;
|
|
25
|
+
retired: number;
|
|
26
|
+
unmapped: number;
|
|
27
|
+
nonSpec: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
|
|
32
|
+
* with one decimal, and the documents the axes exclude (retired / unmapped / non-spec) counted
|
|
33
|
+
* with their reasons. A payload without `linkCensus` (an older server) folds to zeros, never throws.
|
|
34
|
+
*/
|
|
35
|
+
export declare function dashboardCensusExtended(rtm: any, heatmap: any): DashboardCensusExtended;
|
|
16
36
|
export interface LaunchResult {
|
|
17
37
|
url: string;
|
|
18
38
|
port: number;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// @implements A-SPEC-545.3
|
|
3
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
3
|
exports.dashboardCensus = dashboardCensus;
|
|
4
|
+
exports.dashboardCensusExtended = dashboardCensusExtended;
|
|
5
5
|
exports.ensureDashboard = ensureDashboard;
|
|
6
6
|
exports._resetLauncher = _resetLauncher;
|
|
7
7
|
exports._stopAll = _stopAll;
|
|
8
|
+
// @implements A-SPEC-545.3
|
|
9
|
+
const link_census_1 = require("../rtm/link-census");
|
|
8
10
|
/**
|
|
9
11
|
* @implements A-SPEC-545.3
|
|
10
12
|
* Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
|
|
@@ -24,6 +26,27 @@ function dashboardCensus(rtm, heatmap) {
|
|
|
24
26
|
findingsScanned: heatmap?.findingsScanned === true,
|
|
25
27
|
};
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
|
|
31
|
+
* with one decimal, and the documents the axes exclude (retired / unmapped / non-spec) counted
|
|
32
|
+
* with their reasons. A payload without `linkCensus` (an older server) folds to zeros, never throws.
|
|
33
|
+
*/
|
|
34
|
+
function dashboardCensusExtended(rtm, heatmap) {
|
|
35
|
+
const base = dashboardCensus(rtm, heatmap);
|
|
36
|
+
const lc = rtm?.linkCensus;
|
|
37
|
+
const retired = rtm?.retired?.count ?? 0;
|
|
38
|
+
const unmapped = rtm?.unmapped?.count ?? 0;
|
|
39
|
+
const nonSpec = rtm?.nonSpec?.count ?? 0;
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
coveragePct: (0, link_census_1.pct1)(heatmap?.completeCount ?? 0, heatmap?.pipelineCount ?? 0),
|
|
43
|
+
codeLinkedPct: typeof lc?.codeLinkedPct === 'number' ? lc.codeLinkedPct : 0,
|
|
44
|
+
codeLinkedCount: typeof lc?.codeLinked === 'number' ? lc.codeLinked : 0,
|
|
45
|
+
unlinkedCount: Array.isArray(lc?.unlinked) ? lc.unlinked.length : 0,
|
|
46
|
+
unlinkedByReason: { ...(0, link_census_1.emptyByReason)(), ...(lc?.byReason ?? {}) },
|
|
47
|
+
excluded: { total: retired + unmapped + nonSpec, retired, unmapped, nonSpec },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
27
50
|
const live = new Map();
|
|
28
51
|
/**
|
|
29
52
|
* @implements A-SPEC-545.3
|