@holmes-lab/holmes-kit 0.14.0 → 0.16.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 +96 -0
- package/README.md +2 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.d.ts +14 -0
- package/dist/holmes/cli/approve.js +60 -3
- package/dist/holmes/cli/gitignore-merge.js +5 -0
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/governance/approval-queue.d.ts +39 -0
- package/dist/holmes/governance/approval-queue.js +105 -10
- package/dist/holmes/governance/session-context.d.ts +74 -0
- package/dist/holmes/governance/session-context.js +179 -0
- package/dist/holmes/hooks/pre-tool-use.js +5 -2
- package/dist/holmes/hooks/rtm-refresh-child.d.ts +1 -0
- package/dist/holmes/hooks/rtm-refresh-child.js +56 -0
- package/dist/holmes/hooks/rtm-refresh.d.ts +13 -0
- package/dist/holmes/hooks/rtm-refresh.js +76 -0
- package/dist/holmes/hooks/stop.js +42 -0
- package/dist/holmes/mcp/handlers.d.ts +5 -6
- package/dist/holmes/mcp/handlers.js +76 -2
- package/dist/holmes/mcp/server.js +12 -0
- package/dist/holmes/mcp/tool-schemas.js +1 -1
- package/dist/holmes/review/judgement-bundle.d.ts +49 -0
- package/dist/holmes/review/judgement-bundle.js +108 -0
- package/dist/holmes/review/run-replay.d.ts +5 -0
- package/dist/holmes/review/run-replay.js +32 -0
- package/dist/holmes/review/test-outcomes.d.ts +17 -2
- package/dist/holmes/review/test-outcomes.js +54 -15
- package/dist/holmes/rtm/impact-advisory.d.ts +48 -0
- package/dist/holmes/rtm/impact-advisory.js +175 -0
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
- package/dist/holmes/rtm/rtm-builder.js +42 -1
- package/dist/holmes/rtm/rtm-graph.d.ts +16 -1
- package/dist/holmes/rtm/rtm-graph.js +34 -6
- package/dist/holmes/spec/approval-blockers.js +8 -0
- package/dist/holmes/spec/compat-impact.d.ts +31 -0
- package/dist/holmes/spec/compat-impact.js +141 -0
- package/dist/holmes/spec/spec-types.js +3 -1
- package/package.json +1 -1
|
@@ -330,7 +330,7 @@ exports.TOOL_SCHEMAS = {
|
|
|
330
330
|
},
|
|
331
331
|
},
|
|
332
332
|
rtm_impact: {
|
|
333
|
-
description: 'Given changed symbol qualified-names, return the impacted SPEC node ids reachable through @implements/depends_on edges ({ impacted }).',
|
|
333
|
+
description: 'Given changed symbol qualified-names, return the impacted SPEC node ids reachable through @implements/depends_on edges ({ impacted, impactedSummaries: [{id, summary}] — the spec intent sentence beside each id, informational only }).',
|
|
334
334
|
inputSchema: {
|
|
335
335
|
type: 'object',
|
|
336
336
|
properties: {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judgement bundle: what the reviewing agent RECEIVES, split by epistemic kind.
|
|
3
|
+
*
|
|
4
|
+
* The owner's correction is the whole design: this is a TECHNICAL graph, and technical facts are
|
|
5
|
+
* crisp — a symbol is defined in one file or it is not. Every measured failure of graded tools on
|
|
6
|
+
* crisp facts (the ranker deleting its own answer, anchor noise, the 0.25-constant confidence)
|
|
7
|
+
* says the same thing, and the S0 misjudgement decomposition (revision 7) named the exact shapes:
|
|
8
|
+
* 17% of false picks were facts a single crisp query would have refuted (symbol ownership, surface
|
|
9
|
+
* presence), and the other 83% — target attribution — are best treated with HISTORY facts
|
|
10
|
+
* (git log -S is deterministic), not with more similarity.
|
|
11
|
+
*
|
|
12
|
+
* So: FACT results carry a verdict, the query that produced it, and the tree it was true of. There
|
|
13
|
+
* is no field for a similarity or a score — the type is the wall (the QueueEventLite idiom).
|
|
14
|
+
* Candidates stay labeled 'candidate' and never get promoted. One self-evaluation, one optional
|
|
15
|
+
* reinforcement, bounded by structure.
|
|
16
|
+
*/
|
|
17
|
+
export interface FactResult {
|
|
18
|
+
kind: 'symbol-owner' | 'surface-presence' | 'feature-history';
|
|
19
|
+
query: string;
|
|
20
|
+
verdict: boolean | string[];
|
|
21
|
+
basis: string;
|
|
22
|
+
asOf: string;
|
|
23
|
+
}
|
|
24
|
+
export interface GitFacts {
|
|
25
|
+
/** Files defining the named symbol (assignment/def/class shapes) in the pinned tree. */
|
|
26
|
+
grepOwners(name: string): string[];
|
|
27
|
+
/** Does the file already carry any of these terms, in the pinned tree? */
|
|
28
|
+
grepInFile(file: string, terms: string[]): boolean;
|
|
29
|
+
/** Files last touching this term/feature across pre-pin history (git log -S shape). */
|
|
30
|
+
logTouched(term: string): string[];
|
|
31
|
+
/** The pinned tree these answers are true of. */
|
|
32
|
+
asOf(): string;
|
|
33
|
+
}
|
|
34
|
+
export interface JudgementBundle {
|
|
35
|
+
header: string;
|
|
36
|
+
facts: FactResult[];
|
|
37
|
+
reinforced: boolean;
|
|
38
|
+
candidates: Array<{
|
|
39
|
+
file: string;
|
|
40
|
+
excerpt: string;
|
|
41
|
+
label: 'candidate';
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
export declare const MAX_FACT_QUERIES = 8;
|
|
45
|
+
export declare const BUNDLE_HEADER: string;
|
|
46
|
+
export declare function buildJudgementBundle(subject: string, candidates: Array<{
|
|
47
|
+
file: string;
|
|
48
|
+
excerpt: string;
|
|
49
|
+
}>, facts: GitFacts): JudgementBundle;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-567.1
|
|
3
|
+
/**
|
|
4
|
+
* The judgement bundle: what the reviewing agent RECEIVES, split by epistemic kind.
|
|
5
|
+
*
|
|
6
|
+
* The owner's correction is the whole design: this is a TECHNICAL graph, and technical facts are
|
|
7
|
+
* crisp — a symbol is defined in one file or it is not. Every measured failure of graded tools on
|
|
8
|
+
* crisp facts (the ranker deleting its own answer, anchor noise, the 0.25-constant confidence)
|
|
9
|
+
* says the same thing, and the S0 misjudgement decomposition (revision 7) named the exact shapes:
|
|
10
|
+
* 17% of false picks were facts a single crisp query would have refuted (symbol ownership, surface
|
|
11
|
+
* presence), and the other 83% — target attribution — are best treated with HISTORY facts
|
|
12
|
+
* (git log -S is deterministic), not with more similarity.
|
|
13
|
+
*
|
|
14
|
+
* So: FACT results carry a verdict, the query that produced it, and the tree it was true of. There
|
|
15
|
+
* is no field for a similarity or a score — the type is the wall (the QueueEventLite idiom).
|
|
16
|
+
* Candidates stay labeled 'candidate' and never get promoted. One self-evaluation, one optional
|
|
17
|
+
* reinforcement, bounded by structure.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.BUNDLE_HEADER = exports.MAX_FACT_QUERIES = void 0;
|
|
21
|
+
exports.buildJudgementBundle = buildJudgementBundle;
|
|
22
|
+
exports.MAX_FACT_QUERIES = 8;
|
|
23
|
+
exports.BUNDLE_HEADER = 'fact 는 검증된 참/거짓(조회식·기준 트리 동봉), candidate 는 미검증 후보다. 조회가 기억을 이긴다 — '
|
|
24
|
+
+ 'fact 와 충돌하는 전제는 버려라. 기존 소유 파일이 후보에 없으면 신규-파일 가설(빈 선택)을 고려하라.';
|
|
25
|
+
/** Decidable subquery tokens out of a subject line: quoted strings, identifier-shaped words and
|
|
26
|
+
* feature tags — deterministic order, capped. Whatever cannot be extracted stays EVIDENCE-only. */
|
|
27
|
+
function extractTokens(subject) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const push = (t) => { if (t.length >= 3 && !out.includes(t))
|
|
30
|
+
out.push(t); };
|
|
31
|
+
for (const m of subject.matchAll(/"([^"]+)"|'([^']+)'/g))
|
|
32
|
+
push(m[1] ?? m[2]);
|
|
33
|
+
for (const m of subject.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*(?:_[A-Za-z0-9_]+)+|[A-Z]{2,}[A-Z0-9_]*)\b/g))
|
|
34
|
+
push(m[1]);
|
|
35
|
+
for (const m of subject.matchAll(/\bF\d{3}\b/g))
|
|
36
|
+
push(m[0]);
|
|
37
|
+
return out.slice(0, exports.MAX_FACT_QUERIES);
|
|
38
|
+
}
|
|
39
|
+
function buildJudgementBundle(subject, candidates, facts) {
|
|
40
|
+
const asOf = (() => { try {
|
|
41
|
+
return facts.asOf();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return 'unknown';
|
|
45
|
+
} })();
|
|
46
|
+
const results = [];
|
|
47
|
+
const candidateFiles = new Set(candidates.map((c) => c.file));
|
|
48
|
+
const tokens = extractTokens(subject);
|
|
49
|
+
let historyRan = false;
|
|
50
|
+
let reinforced = false;
|
|
51
|
+
// Decidable-first: close what a crisp query CAN close. Each answer carries the query that made it
|
|
52
|
+
// and the tree it is true of — an unanswerable (throwing) query is silently absent, never guessed.
|
|
53
|
+
for (const tok of tokens) {
|
|
54
|
+
try {
|
|
55
|
+
const owners = facts.grepOwners(tok);
|
|
56
|
+
if (owners.length > 0) {
|
|
57
|
+
results.push({ kind: 'symbol-owner', query: tok, verdict: owners,
|
|
58
|
+
basis: `git grep -l '${tok} =' | def/class — 정의 소유 파일`, asOf });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch { /* absent, not guessed */ }
|
|
62
|
+
}
|
|
63
|
+
for (const c of candidates) {
|
|
64
|
+
try {
|
|
65
|
+
if (tokens.length > 0 && facts.grepInFile(c.file, tokens)) {
|
|
66
|
+
results.push({ kind: 'surface-presence', query: `${c.file} ∋ {${tokens.slice(0, 3).join(',')}}`,
|
|
67
|
+
verdict: true, basis: 'git grep — 후보가 해당 표면을 이미 보유', asOf });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { /* absent, not guessed */ }
|
|
71
|
+
}
|
|
72
|
+
const featureTag = tokens.find((t) => /^F\d{3}$/.test(t)) ?? tokens[0];
|
|
73
|
+
if (featureTag !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
const touched = facts.logTouched(featureTag);
|
|
76
|
+
historyRan = true;
|
|
77
|
+
if (touched.length > 0) {
|
|
78
|
+
results.push({ kind: 'feature-history', query: featureTag, verdict: touched,
|
|
79
|
+
basis: `git log -S '${featureTag}' — 이 피처를 마지막으로 만진 파일들`, asOf });
|
|
80
|
+
// Self-evaluation, once: owners entirely OUTSIDE the candidate set is the S0 shape that
|
|
81
|
+
// misled the judge (c0/c17/c25 …) — reinforce with ONE more history probe on the next token.
|
|
82
|
+
if (!touched.some((f) => candidateFiles.has(f))) {
|
|
83
|
+
reinforced = true;
|
|
84
|
+
const second = tokens.find((t) => t !== featureTag);
|
|
85
|
+
if (second !== undefined) {
|
|
86
|
+
try {
|
|
87
|
+
const more = facts.logTouched(second);
|
|
88
|
+
if (more.length > 0) {
|
|
89
|
+
results.push({ kind: 'feature-history', query: second, verdict: more,
|
|
90
|
+
basis: `git log -S '${second}' — 보강 1회(소유 파일이 후보 밖)`, asOf });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* the reinforcement is best-effort too */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
void historyRan;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
header: exports.BUNDLE_HEADER,
|
|
104
|
+
facts: results,
|
|
105
|
+
reinforced,
|
|
106
|
+
candidates: candidates.map((c) => ({ ...c, label: 'candidate' })),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -203,6 +203,9 @@ export declare function runReplay(corpus: ReplayCorpus, limit: number, opts?: {
|
|
|
203
203
|
contentVerify?: boolean;
|
|
204
204
|
/** @implements A-SPEC-485 — holdout window start; default 0 is the pinned main window. */
|
|
205
205
|
offset?: number;
|
|
206
|
+
/** @implements A-SPEC-567.2 — attach the judgement bundle (FACT channel + labels) to each
|
|
207
|
+
* caseDump row. Absent = the row is byte-identical to the pre-option shape. */
|
|
208
|
+
judgementBundle?: boolean;
|
|
206
209
|
/**
|
|
207
210
|
* @implements A-SPEC-487 — per-case dump for the blind judgment protocol. The pins stay on
|
|
208
211
|
* the 1-pass result; the dump carries the 2-pass (semantic-injected) output when
|
|
@@ -225,6 +228,8 @@ export declare function runReplay(corpus: ReplayCorpus, limit: number, opts?: {
|
|
|
225
228
|
truthFiles: string[];
|
|
226
229
|
/** @implements A-SPEC-489 — present only when dumpBodies was asked. */
|
|
227
230
|
bodies?: Record<string, string>;
|
|
231
|
+
/** @implements A-SPEC-567.2 — present only when judgementBundle was asked. */
|
|
232
|
+
judgementBundle?: import('./judgement-bundle').JudgementBundle;
|
|
228
233
|
}) => void;
|
|
229
234
|
/** @implements A-SPEC-487 — 2-pass semantic injection for the dump only, never the pins. */
|
|
230
235
|
productSemantic?: {
|
|
@@ -42,6 +42,7 @@ exports.semanticCaseRanking = semanticCaseRanking;
|
|
|
42
42
|
// @implements A-SPEC-378
|
|
43
43
|
// @implements A-SPEC-402
|
|
44
44
|
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const node_child_process_1 = require("node:child_process");
|
|
45
46
|
const os = __importStar(require("node:os"));
|
|
46
47
|
const path = __importStar(require("node:path"));
|
|
47
48
|
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
@@ -275,12 +276,43 @@ async function runReplay(corpus, limit, opts = {}) {
|
|
|
275
276
|
catch { /* unreadable candidate: omitted */ }
|
|
276
277
|
}
|
|
277
278
|
}
|
|
279
|
+
// @implements A-SPEC-567.2 — the FACT channel reads the PARENT tree and pre-case history
|
|
280
|
+
// only (git object queries by sha — deterministic, and the case commit itself is never
|
|
281
|
+
// consulted, so the blind protocol survives). Every query failure is a silent absence.
|
|
282
|
+
let judgementBundle;
|
|
283
|
+
if (opts.judgementBundle === true) {
|
|
284
|
+
try {
|
|
285
|
+
const { buildJudgementBundle } = require('./judgement-bundle');
|
|
286
|
+
const gitQ = (args) => {
|
|
287
|
+
try {
|
|
288
|
+
return (0, node_child_process_1.execFileSync)('git', ['-C', corpus.root, ...args], { encoding: 'utf8', stdio: 'pipe' });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const stripRef = (line) => line.replace(/^[^:]*:/, '');
|
|
295
|
+
const gitFacts = {
|
|
296
|
+
grepOwners: (name) => [...new Set(gitQ(['grep', '-l', '-E', `(^|[^A-Za-z0-9_])${name}\\s*=|def ${name}|class ${name}`, parent, '--', ...corpus.sourcePathspec])
|
|
297
|
+
.split('\n').filter(Boolean).map(stripRef))].slice(0, 5),
|
|
298
|
+
grepInFile: (file, terms) => gitQ(['grep', '-c', '-E', terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), parent, '--', file]).trim() !== '',
|
|
299
|
+
logTouched: (term) => [...new Set(gitQ(['log', '-S', term, '--name-only', '--format=', '-n', '8', parent, '--', ...corpus.sourcePathspec])
|
|
300
|
+
.split('\n').filter((f) => f && corpus.isSource(f)))].slice(0, 5),
|
|
301
|
+
asOf: () => parent,
|
|
302
|
+
};
|
|
303
|
+
judgementBundle = buildJudgementBundle(c.subject, dumpResult.candidates.map((x) => ({ file: x.file, excerpt: bodies?.[x.file] ?? '' })), gitFacts);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
judgementBundle = undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
278
309
|
opts.caseDump({
|
|
279
310
|
commit: c.commit, subject: c.subject,
|
|
280
311
|
candidates: dumpResult.candidates,
|
|
281
312
|
rankedImpact: dumpResult.impacts?.rankedImpact ?? [],
|
|
282
313
|
truthFiles: c.files,
|
|
283
314
|
...(bodies !== undefined ? { bodies } : {}),
|
|
315
|
+
...(judgementBundle !== undefined ? { judgementBundle } : {}),
|
|
284
316
|
});
|
|
285
317
|
}
|
|
286
318
|
// Same case, same truth, same `topN` the product used — an arm scored against a different
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { TestOutcome } from './test-runner';
|
|
2
2
|
export declare const OUTCOMES_FILE: string;
|
|
3
|
+
export declare function outcomesFilename(replica: string): string;
|
|
4
|
+
export declare function isOutcomesFilename(name: string): boolean;
|
|
3
5
|
/**
|
|
4
6
|
* A durable, append-only record of a per-A-SPEC test outcome (REQ-534 RED-first evidence). Unlike
|
|
5
7
|
* `test-evidence.json` (overwritten each run), the SEQUENCE matters here — a red-assertion followed
|
|
@@ -14,9 +16,22 @@ export interface OutcomeRecord {
|
|
|
14
16
|
head: string;
|
|
15
17
|
testFileDigest?: string;
|
|
16
18
|
}
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Append outcome records as JSONL lines. Fail-open (recording must never break a run).
|
|
21
|
+
*
|
|
22
|
+
* @implements A-SPEC-562.1 — writes to THIS machine's chain (`test-outcomes.<replica>.jsonl`), never
|
|
23
|
+
* to the shared legacy file, so two machines appending in parallel produce two files git merges
|
|
24
|
+
* without a conflict. The call site is unchanged; only the destination moved.
|
|
25
|
+
*/
|
|
18
26
|
export declare function appendOutcomes(root: string, records: OutcomeRecord[]): boolean;
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers).
|
|
29
|
+
*
|
|
30
|
+
* @implements A-SPEC-562.1 — reads the legacy file AND every replica chain, merged by `ts`. The sort
|
|
31
|
+
* is what keeps ART-8 honest after a merge: the article reads a red-assertion FOLLOWED BY a green, and
|
|
32
|
+
* with two machines that pair can straddle two files. A stable sort keeps same-timestamp records in
|
|
33
|
+
* read order rather than inventing an ordering between them.
|
|
34
|
+
*/
|
|
20
35
|
export declare function readOutcomes(root: string): OutcomeRecord[];
|
|
21
36
|
/**
|
|
22
37
|
* @implements A-SPEC-534.5
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.OUTCOMES_FILE = void 0;
|
|
37
|
+
exports.outcomesFilename = outcomesFilename;
|
|
38
|
+
exports.isOutcomesFilename = isOutcomesFilename;
|
|
37
39
|
exports.appendOutcomes = appendOutcomes;
|
|
38
40
|
exports.readOutcomes = readOutcomes;
|
|
39
41
|
exports.buildOutcomeRecords = buildOutcomeRecords;
|
|
@@ -41,11 +43,30 @@ exports.groupOutcomesByAspec = groupOutcomesByAspec;
|
|
|
41
43
|
// @implements A-SPEC-534.3
|
|
42
44
|
const fs = __importStar(require("node:fs"));
|
|
43
45
|
const path = __importStar(require("node:path"));
|
|
46
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
44
47
|
exports.OUTCOMES_FILE = path.join('.ax', 'ledger', 'test-outcomes.jsonl');
|
|
45
|
-
|
|
48
|
+
// @implements A-SPEC-562.1 — outcomes join the ledger's REPLICA convention (A-SPEC-148): one file per
|
|
49
|
+
// machine, so two machines appending never conflict on merge and a colleague's clone carries both
|
|
50
|
+
// histories. The shape mirrors `isLedgerFilename` exactly — legacy single file OR a dot-free segment —
|
|
51
|
+
// because two rules disagreeing about the same file is worse than either being wrong. Wired below.
|
|
52
|
+
const OUTCOMES_LEGACY = 'test-outcomes.jsonl';
|
|
53
|
+
const OUTCOMES_REPLICA_FILE = /^test-outcomes\.([^.]+)\.jsonl$/;
|
|
54
|
+
function outcomesFilename(replica) {
|
|
55
|
+
return `test-outcomes.${replica}.jsonl`;
|
|
56
|
+
}
|
|
57
|
+
function isOutcomesFilename(name) {
|
|
58
|
+
return name === OUTCOMES_LEGACY || OUTCOMES_REPLICA_FILE.test(name);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Append outcome records as JSONL lines. Fail-open (recording must never break a run).
|
|
62
|
+
*
|
|
63
|
+
* @implements A-SPEC-562.1 — writes to THIS machine's chain (`test-outcomes.<replica>.jsonl`), never
|
|
64
|
+
* to the shared legacy file, so two machines appending in parallel produce two files git merges
|
|
65
|
+
* without a conflict. The call site is unchanged; only the destination moved.
|
|
66
|
+
*/
|
|
46
67
|
function appendOutcomes(root, records) {
|
|
47
68
|
try {
|
|
48
|
-
const file = path.join(root,
|
|
69
|
+
const file = path.join(root, '.ax', 'ledger', outcomesFilename((0, replica_id_1.resolveReplicaId)(root)));
|
|
49
70
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
71
|
fs.appendFileSync(file, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
51
72
|
return true;
|
|
@@ -54,30 +75,48 @@ function appendOutcomes(root, records) {
|
|
|
54
75
|
return false;
|
|
55
76
|
}
|
|
56
77
|
}
|
|
57
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers).
|
|
80
|
+
*
|
|
81
|
+
* @implements A-SPEC-562.1 — reads the legacy file AND every replica chain, merged by `ts`. The sort
|
|
82
|
+
* is what keeps ART-8 honest after a merge: the article reads a red-assertion FOLLOWED BY a green, and
|
|
83
|
+
* with two machines that pair can straddle two files. A stable sort keeps same-timestamp records in
|
|
84
|
+
* read order rather than inventing an ordering between them.
|
|
85
|
+
*/
|
|
58
86
|
function readOutcomes(root) {
|
|
59
|
-
|
|
87
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
88
|
+
let names;
|
|
60
89
|
try {
|
|
61
|
-
|
|
90
|
+
names = fs.readdirSync(dir).filter(isOutcomesFilename).sort();
|
|
62
91
|
}
|
|
63
92
|
catch {
|
|
64
93
|
return [];
|
|
65
94
|
}
|
|
66
95
|
const out = [];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
continue;
|
|
96
|
+
let i = 0;
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
let text;
|
|
71
99
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const line of text.split('\n')) {
|
|
106
|
+
const s = line.trim();
|
|
107
|
+
if (!s)
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const r = JSON.parse(s);
|
|
111
|
+
if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
|
|
112
|
+
&& typeof r.ts === 'string' && typeof r.head === 'string') {
|
|
113
|
+
out.push({ r: r, i: i++ });
|
|
114
|
+
}
|
|
76
115
|
}
|
|
116
|
+
catch { /* skip a corrupt line rather than fail the whole read */ }
|
|
77
117
|
}
|
|
78
|
-
catch { /* skip a corrupt line rather than fail the whole read */ }
|
|
79
118
|
}
|
|
80
|
-
return out;
|
|
119
|
+
return out.sort((a, b) => (a.r.ts < b.r.ts ? -1 : a.r.ts > b.r.ts ? 1 : a.i - b.i)).map((e) => e.r);
|
|
81
120
|
}
|
|
82
121
|
/**
|
|
83
122
|
* @implements A-SPEC-534.5
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The impact advisory: what the Files-to-Touch declaration MISSES, computed from the call graph at
|
|
3
|
+
* the moment of sealing. jarvis proposal #1, admitted on our own measured record — of fourteen
|
|
4
|
+
* graph mechanisms, the only win was the IMPACT axis (PPR, three corpora), and REQ-565 established
|
|
5
|
+
* the "declaration vs machine" gate pattern this extends.
|
|
6
|
+
*
|
|
7
|
+
* ONE HOP, NEVER CLOSURE: caller closure measured 59 extra files and zero answers across twelve
|
|
8
|
+
* commits ("closure is impact, not localization"). ADVISORY, NEVER VERDICT: nothing here can change
|
|
9
|
+
* ok/refuse/grant (Judgments must not be budgeted). NO GUESSING: an unreadable graph yields null,
|
|
10
|
+
* not an estimate.
|
|
11
|
+
*/
|
|
12
|
+
export interface GraphLike {
|
|
13
|
+
nodeIdsInFile(relPath: string): string[];
|
|
14
|
+
callersOf(id: string): string[];
|
|
15
|
+
/** @implements A-SPEC-568.2 — the node's intent sentence, or null; information only, never a verdict input. */
|
|
16
|
+
summaryOf(id: string): string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface ImpactAdvisory {
|
|
19
|
+
files: Array<{
|
|
20
|
+
path: string;
|
|
21
|
+
anchors: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
summary: string | null;
|
|
24
|
+
}>;
|
|
25
|
+
}>;
|
|
26
|
+
more: number;
|
|
27
|
+
graphAsOf?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare const ADVISORY_CAP = 10;
|
|
30
|
+
export declare function declaredImpactGap(fttFiles: string[], graph: GraphLike, readFile: (rel: string) => string | null, opts?: {
|
|
31
|
+
cap?: number;
|
|
32
|
+
}): ImpactAdvisory | null;
|
|
33
|
+
/**
|
|
34
|
+
* @implements A-SPEC-566.2
|
|
35
|
+
* The observation ledger: every advisory the act produced, so its false-positive rate can be
|
|
36
|
+
* MEASURED before anyone proposes promoting it to a hard gate (the August rtm_reindex lesson).
|
|
37
|
+
* Repo-relative paths, spec ids and integers only — nothing else has a field.
|
|
38
|
+
*/
|
|
39
|
+
export interface AdvisoryRecord {
|
|
40
|
+
aspec: string;
|
|
41
|
+
files: string[];
|
|
42
|
+
more: number;
|
|
43
|
+
graphAsOf?: string;
|
|
44
|
+
ts: string;
|
|
45
|
+
replica?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function appendImpactAdvisory(root: string, rec: AdvisoryRecord): boolean;
|
|
48
|
+
export declare function readImpactAdvisories(root: string): AdvisoryRecord[];
|
|
@@ -0,0 +1,175 @@
|
|
|
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_CAP = void 0;
|
|
37
|
+
exports.declaredImpactGap = declaredImpactGap;
|
|
38
|
+
exports.appendImpactAdvisory = appendImpactAdvisory;
|
|
39
|
+
exports.readImpactAdvisories = readImpactAdvisories;
|
|
40
|
+
// @implements A-SPEC-566.1
|
|
41
|
+
// @implements A-SPEC-566.2
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const path = __importStar(require("node:path"));
|
|
44
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
45
|
+
exports.ADVISORY_CAP = 10;
|
|
46
|
+
/** The ONLY shape allowed into the ledger: `seg/seg/…` of word characters, dots and dashes —
|
|
47
|
+
* no leading slash, no drive letter, no backslash, no empty or `..` segment. */
|
|
48
|
+
function isRepoRelative(p) {
|
|
49
|
+
if (p === '' || p.includes('\\') || p.includes(':'))
|
|
50
|
+
return false;
|
|
51
|
+
const segs = p.split('/');
|
|
52
|
+
// Round-4: '.' segments made `src/./b.ts` dodge a declared `src/b.ts`, and the ASCII-only \w
|
|
53
|
+
// silently dropped findings in non-ASCII filenames (the 한글파일 evidence discipline of 563.3).
|
|
54
|
+
// Unicode letters/digits are leak-safe; dot-segments are not path characters, they are aliases.
|
|
55
|
+
return segs.every((s) => /^[\p{L}\p{N}_.-]+$/u.test(s) && s !== '..' && s !== '.');
|
|
56
|
+
}
|
|
57
|
+
/** `@implements <spec-id>` ids out of a file's text — the anchor idiom, read-only. */
|
|
58
|
+
function anchorsIn(text) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const m of text.matchAll(/@implements\s+((?:A|T|H|C)-SPEC-[\d.]+|REQ-\d+)/g)) {
|
|
61
|
+
if (!out.includes(m[1]))
|
|
62
|
+
out.push(m[1]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function declaredImpactGap(fttFiles, graph, readFile, opts) {
|
|
67
|
+
try {
|
|
68
|
+
const cap = opts?.cap ?? exports.ADVISORY_CAP;
|
|
69
|
+
const declared = new Set(fttFiles.map((f) => f.replace(/\\/g, '/')));
|
|
70
|
+
const outside = new Set();
|
|
71
|
+
for (const ftt of declared) {
|
|
72
|
+
for (const nodeId of graph.nodeIdsInFile(ftt)) {
|
|
73
|
+
for (const caller of graph.callersOf(nodeId)) {
|
|
74
|
+
const at = caller.lastIndexOf('@');
|
|
75
|
+
if (at === -1)
|
|
76
|
+
continue; // a node without a file cannot be a finding
|
|
77
|
+
const file = caller.slice(at + 1);
|
|
78
|
+
// Adversarial rounds 2-3: the record's no-secret contract says REPO-RELATIVE ONLY. A deny
|
|
79
|
+
// list (round-2: '/', 'C:', '..') let UNC paths through one round later — so this is an
|
|
80
|
+
// ALLOW list: plain slash-separated word segments, no '..' segment, nothing else. What
|
|
81
|
+
// does not look like an in-repo relative path is dropped, never normalized into one.
|
|
82
|
+
if (!isRepoRelative(file))
|
|
83
|
+
continue;
|
|
84
|
+
if (!declared.has(file))
|
|
85
|
+
outside.add(file);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (outside.size === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const sorted = [...outside].sort();
|
|
92
|
+
const shown = sorted.slice(0, cap);
|
|
93
|
+
return {
|
|
94
|
+
files: shown.map((path) => {
|
|
95
|
+
let ids = [];
|
|
96
|
+
try {
|
|
97
|
+
const text = readFile(path);
|
|
98
|
+
if (typeof text === 'string')
|
|
99
|
+
ids = anchorsIn(text);
|
|
100
|
+
}
|
|
101
|
+
catch { /* anchors stay [] */ }
|
|
102
|
+
// @implements A-SPEC-568.2 — the anchor's intent sentence rides BESIDE the id, read from the
|
|
103
|
+
// graph S1 built. Information only: nothing below this line feeds files/more/ordering, and a
|
|
104
|
+
// failing lookup downgrades to null rather than killing the finding (an advisory never guesses).
|
|
105
|
+
return {
|
|
106
|
+
path,
|
|
107
|
+
anchors: ids.map((id) => {
|
|
108
|
+
let summary = null;
|
|
109
|
+
try {
|
|
110
|
+
summary = graph.summaryOf(`SPEC:${id}`);
|
|
111
|
+
}
|
|
112
|
+
catch { /* summary stays null */ }
|
|
113
|
+
return { id, summary };
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
117
|
+
more: sorted.length - shown.length,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
} // an advisory never guesses
|
|
123
|
+
}
|
|
124
|
+
const ADVISORY_FILE_RE = /^impact-advisories\.([^.]+)\.jsonl$/;
|
|
125
|
+
function appendImpactAdvisory(root, rec) {
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(path.join(root, '.ax')))
|
|
128
|
+
return false;
|
|
129
|
+
let replica = 'local';
|
|
130
|
+
try {
|
|
131
|
+
replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
|
|
132
|
+
}
|
|
133
|
+
catch { /* keep the fallback */ }
|
|
134
|
+
const file = path.join(root, '.ax', 'ledger', `impact-advisories.${replica}.jsonl`);
|
|
135
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
136
|
+
fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function readImpactAdvisories(root) {
|
|
144
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
145
|
+
let names;
|
|
146
|
+
try {
|
|
147
|
+
names = fs.readdirSync(dir).filter((n) => ADVISORY_FILE_RE.test(n)).sort();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
let text;
|
|
155
|
+
try {
|
|
156
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const line of text.split('\n')) {
|
|
162
|
+
const s = line.trim();
|
|
163
|
+
if (!s)
|
|
164
|
+
continue;
|
|
165
|
+
try {
|
|
166
|
+
const r = JSON.parse(s);
|
|
167
|
+
if (r && typeof r === 'object' && typeof r.aspec === 'string' && Array.isArray(r.files) && typeof r.ts === 'string') {
|
|
168
|
+
out.push(r);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* a corrupt line never breaks the read */ }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
@@ -223,6 +223,13 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
|
|
|
223
223
|
for (const id of citations.cited)
|
|
224
224
|
if (!matchedSpecs.includes(id))
|
|
225
225
|
matchedSpecs.push(id);
|
|
226
|
+
// A spec-intent-vector injection was wired here (REQ-568 S3) and REVERTED on its pre-registered
|
|
227
|
+
// replay: over 441 held-out traceability cases the assist was byte-identical to this lexical
|
|
228
|
+
// path — long queries already match ~371 specs lexically (the semantic top-3 was a subset in
|
|
229
|
+
// 23/23 samples), and in the lexical-zero segment the A-SPEC-399 reorder-not-admit rule forbids
|
|
230
|
+
// exactly the admission recovery would need. The S-491 embedding win belongs to direct
|
|
231
|
+
// query→file ranking, not to this layer. Do not re-add without a NEW pre-registered corpus of
|
|
232
|
+
// short uncited requests AND an admission rule of its own.
|
|
226
233
|
const matchedSpecSet = new Set(matchedSpecs);
|
|
227
234
|
// Vendored/mirrored trees (review D4): an identical copy under reference/vendor must not outrank —
|
|
228
235
|
// or lexically tie-break above — the live source. Their scores are halved and ties break toward
|
|
@@ -57,6 +57,14 @@ export interface BuildRtmOptions {
|
|
|
57
57
|
*/
|
|
58
58
|
fileDigest?: (sourcePath: string) => string | undefined;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* @implements A-SPEC-568.1
|
|
62
|
+
* `"<title> — <intent first sentence>"`, EXTRACTED — never generated. Same spec store, same bytes:
|
|
63
|
+
* the whole function is whitespace folding, one regex, one slice. A missing, blank or still-TODO
|
|
64
|
+
* intent section degrades to the title alone (spec_create stubs sections as `TODO`, and "TODO" as
|
|
65
|
+
* an intent sentence would be noise wearing a dash).
|
|
66
|
+
*/
|
|
67
|
+
export declare function specSummary(s: Spec): string;
|
|
60
68
|
/**
|
|
61
69
|
* Adds one scanned file's CODE nodes and `implements` edges to the graph,
|
|
62
70
|
* tagged with that file's sourcePath so RtmGraph.removeBySource(f.sourcePath)
|