@holmes-lab/holmes-kit 0.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NAME_MAX = exports.SURFACE_BUDGET = void 0;
4
+ exports.surfaceOf = surfaceOf;
5
+ exports.renderSurface = renderSurface;
6
+ exports.renderTestPoints = renderTestPoints;
7
+ exports.renderDoneWhen = renderDoneWhen;
8
+ /**
9
+ * Total characters the rendered symbol list may occupy.
10
+ *
11
+ * Chosen from measurement, not taste. On the calibration target the 15 clusters hold 164, 132, 68,
12
+ * 53, 11 and ten single-symbol surfaces; listing EVERY one whole costs 11,460 characters (~2.9K
13
+ * tokens) for the entire adoption of a 203-file repository. So the bound exists to stop a
14
+ * pathological monorepo cluster, NOT to trim ordinary ones: at 4,000 every cluster on the target
15
+ * fits whole except the largest, which loses ~15% and says so.
16
+ *
17
+ * A tighter bound was tried first (1,200) and dropped 119 of 164 names — 73% of the largest slice —
18
+ * to save ~2K tokens across a whole adoption. That trade sends the human back to the scan report,
19
+ * which is the toil this REQ exists to remove.
20
+ */
21
+ exports.SURFACE_BUDGET = 4000;
22
+ /**
23
+ * Longest name that may be quoted. A longer one is dropped rather than truncated: a partially
24
+ * printed identifier cannot be searched for, so it is worse than an honest omission count.
25
+ */
26
+ exports.NAME_MAX = 80;
27
+ /** Byte-stable comparator. `localeCompare` is locale-dependent and would break determinism. */
28
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
29
+ /**
30
+ * The top-level, non-underscore symbols of the given files — the PUBLIC SURFACE CANDIDATES.
31
+ *
32
+ * Two mechanical filters, no natural-language judgement:
33
+ *
34
+ * 1. NESTED, decided by line range WITHIN THE SAME FILE: a symbol `s` is nested when some other
35
+ * symbol `o` of that same file satisfies `o.startLine < s.startLine && o.endLine >= s.endLine`.
36
+ * `>=` (not `>`) because a nested definition may end on the same line as its enclosing one.
37
+ * `<` (not `<=`) on the start because two symbols reported at the same start line cannot be
38
+ * ordered by containment — refusing to decide keeps both rather than deleting a real symbol.
39
+ * The comparison is per-file: a wide range in one file must not swallow a narrow one in another.
40
+ * 2. PRIVATE, decided by a leading underscore. `build_briefing` keeps its inner underscores; only
41
+ * the first character is consulted.
42
+ *
43
+ * Sorted by file then start line so the same scan always renders byte-identically.
44
+ */
45
+ function surfaceOf(files) {
46
+ const out = [];
47
+ for (const f of files) {
48
+ for (const s of f.symbols) {
49
+ if (s.name.startsWith('_'))
50
+ continue;
51
+ // `o !== s` is REDUNDANT under the strict `<` — `s.startLine < s.startLine` is false, so a
52
+ // symbol can never contain itself. Measured on 1,101 symbols across three trees: removing it
53
+ // changes zero verdicts. It is kept as the guard that makes the strictness safe to revisit:
54
+ // relaxing the start-line comparison to `<=` without it would make every symbol its own
55
+ // container and empty the surface, which reads as "the scanner found nothing", not as a bug.
56
+ const nested = f.symbols.some((o) => o !== s && o.startLine < s.startLine && o.endLine >= s.endLine);
57
+ if (nested)
58
+ continue;
59
+ out.push({ file: f.sourcePath, name: s.name, kind: s.kind, startLine: s.startLine });
60
+ }
61
+ }
62
+ out.sort((a, b) => cmp(a.file, b.file) || a.startLine - b.startLine || cmp(a.name, b.name));
63
+ return out.map(({ file, name, kind }) => ({ file, name, kind }));
64
+ }
65
+ const EMPTY_SURFACE = '스캐너가 이 슬라이스에서 최상위 심볼을 하나도 찾지 못했다. 이 언어에 심볼 추출이 없거나, ' +
66
+ '이 슬라이스의 코드가 모듈 수준 문장으로만 이루어져 있다는 뜻이다 — 계약이 없다는 뜻은 아니다.';
67
+ const header = (file) => `- \`${file}\`: `;
68
+ /**
69
+ * The surface as a per-file list, bounded.
70
+ *
71
+ * Four rules, the same ones A-SPEC-183 arrived at for the gate's denial text:
72
+ * (1) a name is printed WHOLE or not at all; (2) if nothing fits, report a COUNT instead of a list;
73
+ * (3) the bound is on the TOTAL, not per item; (4) no item passes unconditionally — the first entry
74
+ * is budget-checked like every other, so one absurd path cannot blow the bound.
75
+ */
76
+ function renderSurface(surface) {
77
+ if (surface.length === 0)
78
+ return EMPTY_SURFACE;
79
+ // Grouped through a Map rather than "is this the same file as the previous entry", which would
80
+ // silently fragment a file into several bullets if a caller ever passed an unsorted array.
81
+ // `surfaceOf` sorts, so today the two agree — this keeps them agreeing without the precondition.
82
+ const groups = new Map();
83
+ let used = 0;
84
+ let omitted = 0;
85
+ for (const s of surface) {
86
+ if (s.name.length > exports.NAME_MAX) {
87
+ omitted++;
88
+ continue;
89
+ }
90
+ const existing = groups.get(s.file);
91
+ // A file's header is paid once, by its first accepted name.
92
+ const cost = existing ? s.name.length + 2 : header(s.file).length + s.name.length + 1;
93
+ // Rule (4) — the first entry is budget-checked like every other. `continue`, never `break`: a
94
+ // later short name still fits after a long one is skipped, and `break` would discard it along
95
+ // with everything behind it.
96
+ if (used + cost > exports.SURFACE_BUDGET) {
97
+ omitted++;
98
+ continue;
99
+ }
100
+ used += cost;
101
+ if (existing)
102
+ existing.push(s.name);
103
+ else
104
+ groups.set(s.file, [s.name]);
105
+ }
106
+ const lead = `스캐너가 찾은 최상위 심볼 ${surface.length}개. 이것은 표면 **후보**이지 이 슬라이스가 약속하는 계약이 아니다.`;
107
+ // Rule (2): not one name fit, so say how many there are rather than printing an empty list that
108
+ // reads as "there is nothing here".
109
+ if (groups.size === 0) {
110
+ return `${lead}\n\n이름이 너무 길거나 경로가 너무 깊어 ${omitted}개 모두 여기에 싣지 못했다. 스캔 보고서에서 확인하라.`;
111
+ }
112
+ const body = [...groups].map(([file, names]) => `${header(file)}${names.join(', ')}`).join('\n');
113
+ const tail = omitted > 0 ? `\n\n(그 밖에 ${omitted}개는 길이 상한으로 생략)` : '';
114
+ return `${lead}\n\n${body}${tail}`;
115
+ }
116
+ /**
117
+ * The tests already attached to this slice, and — always — how many the repository could not attach
118
+ * to any slice at all.
119
+ *
120
+ * The unmatched count is not decoration. Measured on the calibration target the matcher attaches 46
121
+ * of 86 test files (53%), and the 40 it refuses are mostly its own rule's limit, not unrelated
122
+ * tests. A list printed without that number reads as "these are the tests for this slice".
123
+ */
124
+ function renderTestPoints(ev) {
125
+ const head = ev.testFiles.length
126
+ ? `이름 증거로 이 슬라이스에 붙은 기존 테스트 ${ev.testFiles.length}개:\n\n` +
127
+ ev.testFiles.map((f) => `- \`${f}\``).join('\n')
128
+ : '이름 증거로 이 슬라이스에 붙은 기존 테스트가 없다. 테스트가 없거나, 그 이름이 대상을 밝히지 않는다는 뜻이다.';
129
+ const gap = ev.testsUnmatched > 0
130
+ ? `\n\n저장소 전체에서 어느 슬라이스에도 붙지 못한 테스트 파일이 ${ev.testsUnmatched}개 있다. ` +
131
+ '위 목록은 이 슬라이스를 덮는 테스트의 전부가 아닐 수 있다 — 매칭은 파일 이름의 정확한 일치만 인정한다.'
132
+ : '';
133
+ return `${head}${gap}`;
134
+ }
135
+ /**
136
+ * A completion criterion that is checkable as written, which is why this section — alone among the
137
+ * three A-SPEC-181 fills — does not keep a TODO and therefore does not block approval.
138
+ *
139
+ * Clause 3 is NEVER dropped when the slice has no attached test. Dropping it would leave 1 and 2
140
+ * standing alone and read as "done"; the absence of a regression control is this slice's largest
141
+ * risk and belongs in the completion criterion, not in silence. Measured on the calibration target:
142
+ * 11 of 15 clusters are in exactly that state.
143
+ */
144
+ function renderDoneWhen(files, testFiles) {
145
+ const third = testFiles.length
146
+ ? `3. 이 슬라이스에 붙은 기존 테스트 ${testFiles.length}개가 여전히 통과한다.`
147
+ : '3. 이 슬라이스를 덮는 기존 테스트가 없다 — 회귀 대조군이 없다는 사실 자체가 이 슬라이스의 ' +
148
+ '가장 큰 위험이며, 그것을 메우는 T-SPEC이 생기기 전까지 이 항은 충족될 수 없다.';
149
+ return [
150
+ `1. 아래 ${files.length}개 파일 밖의 파일이 바뀌지 않는다 — 바뀐다면 슬라이스 경계가 틀린 것이므로 A-SPEC을 다시 자른다.`,
151
+ '2. 이 A-SPEC을 `depends_on`에 담은 승인된 T-SPEC의 시나리오가 모두 통과한다.',
152
+ third,
153
+ ].join('\n');
154
+ }
@@ -0,0 +1,263 @@
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.SEGMENT_SEPARATOR = exports.MAX_TRAILING_DROP = void 0;
37
+ exports.subjectStems = subjectStems;
38
+ exports.qualifierSegments = qualifierSegments;
39
+ exports.mapTestsToClusters = mapTestsToClusters;
40
+ // @implements A-SPEC-126
41
+ const path = __importStar(require("node:path"));
42
+ const draft_1 = require("./draft");
43
+ /**
44
+ * Attach a brownfield project's EXISTING tests to the slices they cover.
45
+ *
46
+ * Why this exists: per-A-SPEC test evidence (`scanTestAnchors` → `computeTestScope` →
47
+ * `writeTestEvidence`) is read from `@implements` anchors inside TEST files, but the scanner
48
+ * deliberately keeps test files out of the production graph — so reverse engineering clustered the
49
+ * production code and left every test unanchored, and the evidence link had to be wired by hand.
50
+ * Measured on a real pytest target, that was 76 files of manual work standing between adoption and
51
+ * any per-slice test evidence at all.
52
+ *
53
+ * The mapping is by NAME EVIDENCE ONLY, and unmatched is a first-class outcome. A test whose subject
54
+ * cannot be identified, or whose subject lives in two different slices, is REPORTED rather than
55
+ * attached: an anchor is an authorship claim, and attaching one on directory proximity would be the
56
+ * guessing this whole slice refuses to do everywhere else.
57
+ */
58
+ /**
59
+ * Candidate subjects a test file's name could be naming, STRONGEST FIRST.
60
+ *
61
+ * The first element is the whole name with its test convention stripped. The rest drop leading
62
+ * `_`-separated segments one at a time, because a test name routinely carries a suite qualifier
63
+ * ahead of its subject — measured on a real Python target, 62 of 74 tests were
64
+ * `tests/golden/test_golden_<subject>.py`, where `golden_` names the suite. A fixed vocabulary of
65
+ * qualifiers ("golden", "unit", "integration", …) would be a guess about someone else's conventions;
66
+ * offering weaker candidates and demanding a UNIQUE production match lets the target's own file names
67
+ * decide which reading is right. An empty list means the name identifies no subject at all.
68
+ */
69
+ function subjectStems(testPath, qualifiers) {
70
+ const base = path.posix.basename(testPath.replace(/\\/g, '/'));
71
+ const name = base.replace(/\.[^.]+$/, ''); // drop the extension
72
+ let stem = null;
73
+ // `x.test.ts` / `x.spec.ts`
74
+ const infix = /^(.+)\.(test|spec)$/i.exec(name);
75
+ if (infix)
76
+ stem = infix[1];
77
+ // `test_x` / `x_test` / `x_tests` / `x_unittest`
78
+ if (!stem) {
79
+ const m = /^test_(.+)$/i.exec(name);
80
+ if (m)
81
+ stem = m[1];
82
+ }
83
+ if (!stem) {
84
+ const m = /^(.+)_(tests?|unittest)$/i.exec(name);
85
+ if (m)
86
+ stem = m[1];
87
+ }
88
+ // `AppTest` / `AppTests` / `AppTestCase` — JVM/.NET. Requires something before the suffix, so a
89
+ // file named exactly `Test.java` names no subject.
90
+ if (!stem) {
91
+ const m = /^(.+?)(Test|Tests|TestCase|TestCases)$/.exec(name);
92
+ if (m)
93
+ stem = m[1];
94
+ }
95
+ // `conftest.py`, `tests.py`, `mod.rs`, `integration.rs` — real test files that name no subject.
96
+ if (!stem)
97
+ return [];
98
+ const segments = stem.toLowerCase().split('_').filter(Boolean);
99
+ // Drop leading segments ONLY while they are corpus-attested suite qualifiers. Dropping any leading
100
+ // segment instead attached `test_golden_capabilities_tool.py` to `src/core/tool.py` — a generic
101
+ // trailing word that happened to be unique, which is an anchor placed on no real evidence.
102
+ const out = [];
103
+ for (let i = 0; i < segments.length; i++) {
104
+ out.push(segments.slice(i).join('_'));
105
+ if (!qualifiers.has(segments[i]))
106
+ break;
107
+ }
108
+ return out;
109
+ }
110
+ /**
111
+ * Leading name segments that the corpus itself shows to be SUITE QUALIFIERS rather than subjects.
112
+ *
113
+ * A qualifier is a word many of a project's tests start with (`golden_`, `unit_`, `integration_`),
114
+ * which is a property of the corpus and therefore measurable — as opposed to a vocabulary guessed in
115
+ * advance, which would encode one project's conventions as everyone's. The thresholds keep a
116
+ * coincidence from qualifying: at least three files AND at least a tenth of the suite.
117
+ */
118
+ function qualifierSegments(testFiles) {
119
+ const counts = new Map();
120
+ for (const f of testFiles) {
121
+ const [first] = subjectStems(f, new Set()).flatMap((s) => s.split('_'));
122
+ if (first)
123
+ counts.set(first, (counts.get(first) ?? 0) + 1);
124
+ }
125
+ const min = Math.max(3, Math.ceil(testFiles.length * 0.1));
126
+ return new Set([...counts].filter(([, n]) => n >= min).map(([seg]) => seg));
127
+ }
128
+ /**
129
+ * How many trailing segments the `shortened` reading may drop.
130
+ *
131
+ * ONE, and the value is measured rather than chosen. At two, the calibration target gains four more
132
+ * matches and two of them are the false anchor this file already warns about above:
133
+ * `test_golden_tool_dispatch_e2e.py` lands on `src/core/tool.py`. The more segments a reading
134
+ * discards, the more generic the remainder becomes — and a generic word is the kind that happens to
135
+ * be unique by accident.
136
+ */
137
+ exports.MAX_TRAILING_DROP = 1;
138
+ /**
139
+ * Segment separators recognised WHEN WEAKENING a subject — `_` for python-style names, `-` for
140
+ * JS/TS-style ones.
141
+ *
142
+ * Deliberately not shared with `subjectStems`/`qualifierSegments`: widening the split there would
143
+ * change which leading segments count as suite qualifiers, and that could move matches that already
144
+ * exist. The weakening steps only ever run where the exact reading found nothing, so they can be
145
+ * broader without disturbing anything.
146
+ */
147
+ exports.SEGMENT_SEPARATOR = /[_-]/;
148
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
149
+ const READINGS = [
150
+ // Indexed, like the original rule — a full scan here would turn the whole pass quadratic for the
151
+ // reading that answers most of the corpus.
152
+ { via: 'exact', owners: (s, prod) => prod.byStem.get(s) ?? [] },
153
+ {
154
+ via: 'prefix',
155
+ // The only reading that must scan: a prefix is not a hash key. It runs solely for test files no
156
+ // stronger reading could place, so the scan is over the small tail rather than the corpus.
157
+ //
158
+ // The boundary check is the safeguard. Without it `place` attaches to `places.py` — an overlap
159
+ // of spelling, not of name. `p.stem.length > s.length` keeps this from restating `exact`.
160
+ owners: (s, prod) => prod.all.filter((p) => p.stem.length > s.length && p.stem.startsWith(s) && exports.SEGMENT_SEPARATOR.test(p.stem[s.length])),
161
+ },
162
+ {
163
+ via: 'shortened',
164
+ owners: (s, prod) => {
165
+ const segs = s.split(exports.SEGMENT_SEPARATOR).filter(Boolean);
166
+ // A single-segment subject yields nothing: dropping its only segment leaves the empty string,
167
+ // which would match on emptiness rather than on evidence.
168
+ if (segs.length <= exports.MAX_TRAILING_DROP)
169
+ return [];
170
+ const sep = s.includes('-') && !s.includes('_') ? '-' : '_';
171
+ // The shortened form is still an EXACT name, so it is indexed too.
172
+ return prod.byStem.get(segs.slice(0, segs.length - exports.MAX_TRAILING_DROP).join(sep)) ?? [];
173
+ },
174
+ },
175
+ ];
176
+ function mapTestsToClusters(testFiles, clusters) {
177
+ // stem -> the production files that carry it, with the cluster each belongs to.
178
+ const byStem = new Map();
179
+ const prod = [];
180
+ for (const c of clusters) {
181
+ const key = (0, draft_1.clusterKeyOf)(c);
182
+ for (const f of c.files) {
183
+ const stem = path.posix.basename(f).replace(/\.[^.]+$/, '').toLowerCase();
184
+ if (!byStem.has(stem))
185
+ byStem.set(stem, []);
186
+ const owner = { file: f, clusterKey: key, stem };
187
+ byStem.get(stem).push(owner);
188
+ prod.push(owner);
189
+ }
190
+ }
191
+ const matched = [];
192
+ const unmatched = [];
193
+ const qualifiers = qualifierSegments(testFiles);
194
+ const index = { byStem, all: prod };
195
+ // @implements A-SPEC-185
196
+ // Does THIS project keep its tests beside the code they cover?
197
+ //
198
+ // Read from the LAYOUT, not from any match: how many test files sit in a directory that also
199
+ // holds production code. That makes the answer available even when nothing matched exactly —
200
+ // which is precisely the project where the weakened readings matter most, and where deciding
201
+ // from matches would have no evidence to decide on.
202
+ //
203
+ // Measured: the calibration target has 0 of 86 test files inside a production directory (a
204
+ // separate `tests/` tree); this repository has 113 of 129 (88%). The signal is 0% against 88%,
205
+ // not a judgement call.
206
+ const productionDirs = new Set(clusters.flatMap((c) => c.files).map((f) => path.posix.dirname(f)));
207
+ const inside = testFiles.filter((f) => productionDirs.has(path.posix.dirname(f))).length;
208
+ const testsSitBesideSources = inside * 2 >= testFiles.length;
209
+ for (const file of [...testFiles].sort(cmp)) {
210
+ const stems = subjectStems(file, qualifiers);
211
+ if (stems.length === 0) {
212
+ unmatched.push({ file, reason: 'the file name states no subject to map it to' });
213
+ continue;
214
+ }
215
+ // The STRONGEST stem that matches anything decides. A weaker one is only consulted while nothing
216
+ // has matched at all — once a stem finds owners, an ambiguity among them is a real ambiguity, and
217
+ // falling through to a less specific name would paper over it with a worse guess.
218
+ //
219
+ // @implements A-SPEC-185
220
+ // The same rule now governs the READINGS as well: a whole reading is tried across every stem
221
+ // before the next, weaker one begins, and the first reading to find any owner ends the search.
222
+ // Both loops stop on the first hit, so a test that already matched exactly cannot be re-decided
223
+ // by a weaker rule — which is why this change cannot move an existing match.
224
+ let hit = null;
225
+ for (const reading of READINGS) {
226
+ for (const stem of stems) {
227
+ // @implements A-SPEC-185
228
+ // A weakened name searches the WHOLE production set, so it can land in an unrelated
229
+ // directory. Measured, that produced two false anchors on this very repository:
230
+ // `mcp/surface-conformance.test.ts` (which tests stdio-client) attached to
231
+ // `reverse/surface.ts`, and `cpg/scan-completeness.test.ts` attached to `reverse/scan.ts`.
232
+ //
233
+ // The constraint is NOT "attach by proximity" — proximity never creates a match here. It
234
+ // refuses a weakened match that contradicts the project's OWN observed layout, and whether
235
+ // the project has that layout is measured from its already-settled exact matches rather
236
+ // than assumed. `exact` is exempt: a whole-name match is evidence enough, and leaving it
237
+ // alone is what keeps this change from moving any existing attachment.
238
+ const owners = reading.owners(stem, index).filter((o) => reading.via === 'exact' || !testsSitBesideSources || path.posix.dirname(o.file) === path.posix.dirname(file));
239
+ if (owners.length > 0) {
240
+ hit = { stem, owners, via: reading.via };
241
+ break;
242
+ }
243
+ }
244
+ if (hit)
245
+ break;
246
+ }
247
+ if (!hit) {
248
+ unmatched.push({ file, reason: `no candidate file named "${stems[0]}"` });
249
+ continue;
250
+ }
251
+ const keys = [...new Set(hit.owners.map((o) => o.clusterKey))].sort(cmp);
252
+ if (keys.length > 1) {
253
+ // Two slices define the same name; picking one would be a coin flip that mis-credits evidence.
254
+ // Reached from ANY reading, and it never falls through to a weaker one: a less specific name
255
+ // that happens to be unique is the false anchor this file was already burned by.
256
+ unmatched.push({ file, reason: `subject "${hit.stem}" is defined in ${keys.length} clusters` });
257
+ continue;
258
+ }
259
+ const subject = hit.owners.map((o) => o.file).sort(cmp)[0];
260
+ matched.push({ file, clusterKey: keys[0], subject, via: hit.via });
261
+ }
262
+ return { matched, unmatched };
263
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SCANNABLE_NOTE = void 0;
4
+ exports.partitionChangedFiles = partitionChangedFiles;
5
+ // @implements A-SPEC-102.1
6
+ /**
7
+ * Partitions changed files into two sets: those the CpgScanner ingested
8
+ * (scanned) and those it skipped (unscanned).
9
+ *
10
+ * A changed file is considered "scanned" iff its repo-relative POSIX path
11
+ * is present in the scannedSourcePaths set. This provides an honest signal
12
+ * about what the review actually analyzed vs what it did not (REQ-124 gate 2a).
13
+ *
14
+ * @param changedFiles - List of repo-relative POSIX paths that changed
15
+ * @param scannedSourcePaths - Set of repo-relative POSIX paths the scanner ingested
16
+ * @returns Object with sorted, deduplicated scanned and unscanned arrays
17
+ */
18
+ function partitionChangedFiles(changedFiles, scannedSourcePaths) {
19
+ const scannedSet = new Set(scannedSourcePaths);
20
+ const uniqueChanged = [...new Set(changedFiles)];
21
+ const scanned = uniqueChanged
22
+ .filter((f) => scannedSet.has(f))
23
+ .sort();
24
+ const unscanned = uniqueChanged
25
+ .filter((f) => !scannedSet.has(f))
26
+ .sort();
27
+ return { scanned, unscanned };
28
+ }
29
+ /**
30
+ * Human-readable description of what the CpgScanner ingests.
31
+ * Used to make the "unscanned" signal self-documenting in review output.
32
+ */
33
+ exports.SCANNABLE_NOTE = 'CpgScanner analyzes the TS/JS family (.ts/.mts/.cts/.tsx/.jsx/.js/.mjs/.cjs, incl. JSX), Python (.py), C# (.cs), Java (.java), Go (.go), Rust (.rs), and C++ (.cpp/.cc/.cxx/.hpp/.hh/.h) — the Aider-Polyglot + C# language set — extracting function/class/method symbols and @implements anchors. Excludes *.test.*/*.spec.*, Python (test_*.py/*_test.py) and Go (*_test.go) test files; skips node_modules/dist/.git. Call/import EDGES are extracted for TS/JS only (other languages: symbols + anchors, edges deferred). Other languages (e.g. Kotlin/Swift/Ruby/PHP) appear as unscanned.';
@@ -0,0 +1,123 @@
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.FindingsLedger = void 0;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ // @implements A-SPEC-102.1
40
+ /**
41
+ * Append-only, spec/code-linked audit trail of review findings. Each call to
42
+ * `record` appends one JSON line per finding to a `.jsonl` file — never
43
+ * truncated, never rewritten — so the file itself is the durable evidence
44
+ * trail (Decision-Event style) that a diff-only review lacks.
45
+ *
46
+ * Ids and timestamps are caller-supplied: this ledger has no clock and no id
47
+ * generator, so recording is fully deterministic given its inputs.
48
+ */
49
+ class FindingsLedger {
50
+ path;
51
+ constructor(path) {
52
+ this.path = path;
53
+ }
54
+ /**
55
+ * @implements A-SPEC-157
56
+ * `basis` is a SEPARATE argument, not a field on the findings, so the caller cannot choose it.
57
+ * Optional because making it required would break every existing call site without declaring it
58
+ * (ADR-013 tier ③); when it is absent, no basis is written and that absence is observable.
59
+ */
60
+ record(findings, basis) {
61
+ if (findings.length === 0)
62
+ return;
63
+ const lines = findings.map((f) => FindingsLedger.serialize(f, basis)).join('');
64
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
65
+ fs.appendFileSync(this.path, lines, 'utf8');
66
+ }
67
+ list(filter) {
68
+ let raw;
69
+ try {
70
+ raw = fs.readFileSync(this.path, 'utf8');
71
+ }
72
+ catch (err) {
73
+ // Missing file: no findings recorded yet, not an error. Any other
74
+ // failure (EACCES, a directory at this path, etc.) must NOT be
75
+ // swallowed: silently returning [] here would make review_status
76
+ // report blocked:false despite recorded open critical/important
77
+ // findings — a silent gate pass.
78
+ if (err.code === 'ENOENT')
79
+ return [];
80
+ throw err;
81
+ }
82
+ const findings = [];
83
+ for (const line of raw.split('\n')) {
84
+ if (line.trim().length === 0)
85
+ continue;
86
+ try {
87
+ const parsed = JSON.parse(line);
88
+ // `null`/scalars/`[]` ARE valid JSON, so parse alone let them through — and a null row
89
+ // crashed every consumer that dereferenced `.status` (measured: verifyConstitution threw,
90
+ // the Stop hook's outer catch exited 0, and the gate failed OPEN over an open critical).
91
+ // A row without a string id is the same corrupt-line class as unparseable JSON.
92
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
93
+ || typeof parsed.id !== 'string')
94
+ continue;
95
+ findings.push(parsed);
96
+ }
97
+ catch {
98
+ // Corrupt line: skip it rather than throwing away the whole ledger.
99
+ continue;
100
+ }
101
+ }
102
+ return findings.filter((f) => (filter?.status === undefined || f.status === filter.status) &&
103
+ (filter?.severity === undefined || f.severity === filter.severity));
104
+ }
105
+ /** Ordered-object serialization so identical findings always produce identical bytes. */
106
+ static serialize(f, basis) {
107
+ // Rebuilding from a whitelist is what makes the caller's own `basis` unable to survive: it is
108
+ // never copied from `f`, so there is no path — not even "observe nothing and let theirs
109
+ // through" — by which a supplied value reaches the ledger.
110
+ const ordered = {
111
+ id: f.id,
112
+ severity: f.severity,
113
+ category: f.category,
114
+ file: f.file,
115
+ specRef: f.specRef,
116
+ summary: f.summary,
117
+ status: f.status,
118
+ ...(basis !== undefined ? { basis } : {}),
119
+ };
120
+ return JSON.stringify(ordered) + '\n';
121
+ }
122
+ }
123
+ exports.FindingsLedger = FindingsLedger;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assembleReviewPackage = assembleReviewPackage;
4
+ const scope_1 = require("./scope");
5
+ const bundler_1 = require("../context/bundler");
6
+ const DEFAULT_MAX_SEEDS = 8;
7
+ /**
8
+ * Assembles a review package: the review scope (impacted specs, acceptance
9
+ * criteria, coverage gaps, etc.) plus a graph-scoped context bundle seeded at
10
+ * each impacted spec AND at each changed code symbol, so a reviewer sees the
11
+ * impacted specs, the changed code itself, and their neighborhoods — the
12
+ * surpass point over a diff-only review.
13
+ *
14
+ * Pure/deterministic given the injected content source: no Date, randomness,
15
+ * or LLM calls. Same inputs always yield a deep-equal ReviewPackage.
16
+ */
17
+ // @implements A-SPEC-102.1
18
+ function assembleReviewPackage(graph, specs, changedSymbols, content, budget, opts) {
19
+ const scope = (0, scope_1.computeReviewScope)(graph, specs, changedSymbols);
20
+ const maxSeeds = opts?.maxSeeds ?? DEFAULT_MAX_SEEDS;
21
+ const perSeedBudget = opts?.perSeedBudget ?? budget;
22
+ // Seed universe = impacted specs (SPEC:*) ∪ changed code symbols (CODE:*),
23
+ // deduped and sorted into a single deterministic total order.
24
+ // @implements A-SPEC-121.4
25
+ // Resolve each changed qn to its REAL (source_path-qualified) node id(s) via
26
+ // graph.codeNodeIds — a bare `CODE:<qn>` id matches no node in the graph
27
+ // post-A-SPEC-121.3, so BFS from it would silently yield an empty
28
+ // (content-less) bundle instead of the actual changed-symbol content. A qn
29
+ // with no matching node contributes no seed at all (empty array, not a
30
+ // crash); a qn shared across files contributes one seed per file.
31
+ const codeSeeds = scope.changedSymbols.flatMap((qn) => graph.codeNodeIds(qn));
32
+ const seedUniverse = [...new Set([...scope.impactedSpecs, ...codeSeeds])].sort();
33
+ const seeds = seedUniverse.slice(0, maxSeeds);
34
+ const droppedSeeds = seedUniverse.slice(maxSeeds).sort();
35
+ const bundles = seeds.map((seedId) => ({
36
+ seedId,
37
+ bundle: new bundler_1.ContextBundler(graph, content).getContextBundle(seedId, perSeedBudget),
38
+ }));
39
+ return { scope, bundles, droppedSeeds };
40
+ }