@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,336 @@
|
|
|
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.SUPPORTED_TEST_FRAMEWORKS = void 0;
|
|
37
|
+
exports.accountChangedFiles = accountChangedFiles;
|
|
38
|
+
exports.countTestCases = countTestCases;
|
|
39
|
+
exports.extractAnchors = extractAnchors;
|
|
40
|
+
exports.scanTestAnchors = scanTestAnchors;
|
|
41
|
+
exports.computeTestScope = computeTestScope;
|
|
42
|
+
// @implements A-SPEC-121.2
|
|
43
|
+
const fs = __importStar(require("node:fs"));
|
|
44
|
+
const path = __importStar(require("node:path"));
|
|
45
|
+
const rtm_check_1 = require("./rtm-check");
|
|
46
|
+
const DEFAULTS = { partialBlast: 3, fullBlast: 12 };
|
|
47
|
+
const stripSpecPrefix = (id) => (id.startsWith('SPEC:') ? id.slice('SPEC:'.length) : id);
|
|
48
|
+
// "Is this a test file?" must be ONE predicate. Two copies had already drifted: the scanner excluded
|
|
49
|
+
// JVM/.NET/Rust/C++ conventions from the production graph while this side still only recognized
|
|
50
|
+
// TS/JS, Python `test_*` and Go — so those languages' tests were in neither set, contributing no
|
|
51
|
+
// evidence while also not being production. The shared table also keeps the original constraint that
|
|
52
|
+
// a test file must be CODE (adversarial finding: `.spec.md` prose was accepted and its English
|
|
53
|
+
// "it (" counted as evidence), since every pattern in it ends in a code extension.
|
|
54
|
+
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
55
|
+
function accountChangedFiles(changedFiles, scanEntry) {
|
|
56
|
+
const anchorImpacted = new Set();
|
|
57
|
+
const changedTestFiles = [];
|
|
58
|
+
const unresolvedFiles = [];
|
|
59
|
+
for (const rel of changedFiles) {
|
|
60
|
+
if (rel.startsWith('.ax/'))
|
|
61
|
+
continue; // governance/derived state, never source
|
|
62
|
+
if ((0, cpg_scanner_1.isTestFile)(rel)) {
|
|
63
|
+
changedTestFiles.push(rel);
|
|
64
|
+
continue;
|
|
65
|
+
} // lane 3
|
|
66
|
+
if (!cpg_scanner_1.SCANNABLE_EXTENSIONS.some((ext) => rel.endsWith(ext)))
|
|
67
|
+
continue; // docs/config: no lane needed
|
|
68
|
+
const entry = scanEntry(rel);
|
|
69
|
+
if (entry && entry.symbolCount > 0)
|
|
70
|
+
continue; // lane 1 owns it
|
|
71
|
+
if (entry && entry.implementsSpecs.length > 0) { // lane 2
|
|
72
|
+
for (const id of entry.implementsSpecs)
|
|
73
|
+
anchorImpacted.add(id);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
unresolvedFiles.push(rel); // no lane — including never-scanned
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
anchorImpactedSpecs: [...anchorImpacted].sort(),
|
|
80
|
+
changedTestFiles: changedTestFiles.sort(),
|
|
81
|
+
unresolvedFiles: unresolvedFiles.sort(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Count EXECUTABLE test cases in test-file source (H1/ART-4 evidence). Adversarial review proved a
|
|
86
|
+
* naive /\bit\(/ count is sham-able: commented-out `// it(`, string literals `'it(here)'`, and prose
|
|
87
|
+
* all counted, while real `it.each(...)('…')` did not. This strips comments and string/template
|
|
88
|
+
* literals FIRST, then counts `it|test|fit` heads including the `.each(...)(` form, and excludes
|
|
89
|
+
* `.skip`/`.todo`/`xit`/`xtest` (never execute), and a leading-`.` lookbehind excludes METHOD calls
|
|
90
|
+
* (`regex.test('x')` is not a test case — adversarial finding: 30 fake cases in 30 `R.test()` lines).
|
|
91
|
+
*
|
|
92
|
+
* Files containing ANY suite-skip construct (`describe.skip`, `xdescribe`, `describe[...]`) forfeit
|
|
93
|
+
* their evidence entirely (count 0). A balanced-paren strip proved gameable in the OVER direction (an
|
|
94
|
+
* extra `)` inside the skipped body — e.g. a regex literal — terminated the strip early and revived
|
|
95
|
+
* the sham), so the conservative rule is total: skip markers ⇒ no credit. Fewer counted cases can
|
|
96
|
+
* only make the gate stricter, never falsely satisfied.
|
|
97
|
+
*
|
|
98
|
+
* HONEST SCOPE: this is a syntactic LOWER-BOUND evidence counter, not execution proof — `if(false)`
|
|
99
|
+
* wrappers or aliasing can still fabricate heads. Execution proof is the test RUNNER's job
|
|
100
|
+
* (planTestRun/runTestScope); this counter only stops declared coverage over an absent/empty suite.
|
|
101
|
+
*/
|
|
102
|
+
/** Strip block/line comments and string literals from C-family source. */
|
|
103
|
+
const stripCFamily = (src) => src
|
|
104
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments
|
|
105
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1 ') // line comments (not ://)
|
|
106
|
+
.replace(/'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g, '""');
|
|
107
|
+
/** Strip `#` comments, docstrings and string literals from Python source. */
|
|
108
|
+
const stripPython = (src) => src
|
|
109
|
+
.replace(/'''[\s\S]*?'''|"""[\s\S]*?"""/g, '""') // docstrings first — they may contain #
|
|
110
|
+
.replace(/(^|[^\\])#[^\n]*/g, '$1 ')
|
|
111
|
+
.replace(/'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"/g, '""');
|
|
112
|
+
/**
|
|
113
|
+
* One rule per test framework. Before this table, every non-JS framework counted ZERO — measured:
|
|
114
|
+
* pytest 3 cases -> 0, JUnit 2 -> 0, xUnit 2 -> 0, cargo 2 -> 0, gtest 3 -> 0, go test 2 -> 0. That
|
|
115
|
+
* is not a cosmetic gap: ART-4's syntactic baseline reads this number, so every anchored A-SPEC in a
|
|
116
|
+
* non-JS project looked like it had no tests, and the Stop gate would flag a violation forever.
|
|
117
|
+
*
|
|
118
|
+
* Every rule preserves the invariant this counter was built around: it is a sham-resistant LOWER
|
|
119
|
+
* BOUND. Under-counting only makes the gate stricter; over-counting would let declared coverage sit
|
|
120
|
+
* on an absent suite. That is why per-case skips are SUBTRACTED rather than attributed — subtracting
|
|
121
|
+
* an unrelated marker errs toward fewer cases, which is the safe direction.
|
|
122
|
+
*/
|
|
123
|
+
const FRAMEWORKS = [
|
|
124
|
+
{
|
|
125
|
+
id: 'jest',
|
|
126
|
+
ext: /\.(ts|mts|cts|tsx|jsx|js|mjs|cjs)$/i,
|
|
127
|
+
strip: stripCFamily,
|
|
128
|
+
suiteSkip: /\bdescribe\s*(?:\.\s*skip|\[\s*['"]skip['"]\s*\])|\bxdescribe\b/,
|
|
129
|
+
// Head must be a STANDALONE identifier (not `.test(`/`.it(` method calls) followed directly by
|
|
130
|
+
// `(` or by `.each(...)(`: `it.skip(`/`it.todo(`/`xit(` never match — no subtraction needed.
|
|
131
|
+
head: /(?<![.\w])(?:it|test|fit)(?:\s*\.\s*each\s*\([^)]*\)\s*)?\s*\(/g,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'pytest',
|
|
135
|
+
ext: /\.py$/i,
|
|
136
|
+
strip: stripPython,
|
|
137
|
+
// Module-level `pytestmark = pytest.mark.skip` disables the whole file, the direct analogue of
|
|
138
|
+
// `describe.skip`.
|
|
139
|
+
suiteSkip: /^\s*pytestmark\s*=\s*[^\n]*\bskip/m,
|
|
140
|
+
head: /^[ \t]*(?:async[ \t]+)?def[ \t]+test_?\w*[ \t]*\(/gm,
|
|
141
|
+
caseSkip: /@\s*(?:pytest\.mark\.skip\w*|unittest\.skip\w*)\b/g,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'junit',
|
|
145
|
+
ext: /\.java$/i,
|
|
146
|
+
strip: stripCFamily,
|
|
147
|
+
// A class-level @Disabled/@Ignore sits above the class declaration — but real code stacks
|
|
148
|
+
// annotations (`@Disabled("wip")` then `@ExtendWith(...)` then the class), so intervening
|
|
149
|
+
// annotation lines must be allowed. Requiring adjacency missed exactly that, and the per-case
|
|
150
|
+
// subtraction masked it by coincidentally landing on 1 instead of 0.
|
|
151
|
+
suiteSkip: /@\s*(?:Disabled|Ignore)\b[^\n]*\n(?:\s*@\w[^\n]*\n)*\s*(?:public\s+|final\s+|abstract\s+|static\s+)*class\b/,
|
|
152
|
+
head: /@\s*(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/g,
|
|
153
|
+
caseSkip: /@\s*(?:Disabled|Ignore)\b/g,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'dotnet',
|
|
157
|
+
ext: /\.cs$/i,
|
|
158
|
+
strip: stripCFamily,
|
|
159
|
+
// NUnit/MSTest disable a whole fixture with a class-level [Ignore]. Attribute lines stack the
|
|
160
|
+
// same way Java's annotations do (`[Ignore("wip")]` then `[TestFixture]` then the class).
|
|
161
|
+
suiteSkip: /\[\s*Ignore[^\]]*\][^\n]*\n(?:\s*\[[^\n]*\n)*\s*(?:public\s+|internal\s+|sealed\s+|partial\s+|abstract\s+|static\s+)*class\b/,
|
|
162
|
+
head: /\[\s*(?:Fact|Theory|Test|TestCase|TestMethod|DataTestMethod)\b/g,
|
|
163
|
+
// xUnit disables a single case with `[Fact(Skip = "…")]`; NUnit/MSTest use [Ignore].
|
|
164
|
+
caseSkip: /\[\s*Ignore\b|\bSkip\s*=\s*""/g,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'cargo',
|
|
168
|
+
ext: /\.rs$/i,
|
|
169
|
+
strip: stripCFamily,
|
|
170
|
+
// `#![ignore]`-style inner attributes are not a thing for tests; a Rust file has no true
|
|
171
|
+
// suite-level skip, so nothing forfeits the file. Kept explicit rather than omitted.
|
|
172
|
+
suiteSkip: /(?!)/,
|
|
173
|
+
head: /#\s*\[\s*(?:\w+\s*::\s*)?test\s*\]/g,
|
|
174
|
+
caseSkip: /#\s*\[\s*ignore\b/g,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'gtest',
|
|
178
|
+
ext: /\.(cpp|cc|cxx|hpp|hh|h)$/i,
|
|
179
|
+
strip: stripCFamily,
|
|
180
|
+
suiteSkip: /(?!)/,
|
|
181
|
+
head: /\b(?:TYPED_)?TEST(?:_F|_P|_CASE)?\s*\(/g,
|
|
182
|
+
// gtest disables by prefixing the suite or the case name with DISABLED_.
|
|
183
|
+
caseSkip: /\b(?:TYPED_)?TEST\w*\s*\(\s*(?:DISABLED_\w*|\w+\s*,\s*DISABLED_\w*)/g,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'gotest',
|
|
187
|
+
ext: /\.go$/i,
|
|
188
|
+
strip: stripCFamily,
|
|
189
|
+
suiteSkip: /(?!)/,
|
|
190
|
+
// Requiring the *testing.T parameter is what separates a real test from a `TestHelper` helper.
|
|
191
|
+
head: /\bfunc\s+(?:Test|Fuzz)\w*\s*\(\s*\w+\s+\*testing\.[TF]\s*\)/g,
|
|
192
|
+
caseSkip: /\bt\s*\.\s*Skip(?:Now|f)?\s*\(/g,
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
const ruleFor = (filePath) => (filePath && FRAMEWORKS.find((f) => f.ext.test(filePath))) || FRAMEWORKS[0];
|
|
196
|
+
/** Frameworks this counter understands, for diagnostics and tests. */
|
|
197
|
+
exports.SUPPORTED_TEST_FRAMEWORKS = FRAMEWORKS.map((f) => f.id);
|
|
198
|
+
function countTestCases(source, filePath) {
|
|
199
|
+
const rule = ruleFor(filePath);
|
|
200
|
+
const stripped = rule.strip(source);
|
|
201
|
+
// Suite-skip detection runs on the ORIGINAL source: string-stripping turns `describe['skip'](`
|
|
202
|
+
// into `describe[""](`, hiding the marker (round-2 test caught this). Comments are already
|
|
203
|
+
// irrelevant here — a commented-out skip marker only makes the counter stricter (forfeits), which
|
|
204
|
+
// is the safe direction for an evidence counter.
|
|
205
|
+
if (rule.suiteSkip.test(source))
|
|
206
|
+
return 0;
|
|
207
|
+
const heads = stripped.match(rule.head) ?? [];
|
|
208
|
+
const skips = rule.caseSkip ? (stripped.match(rule.caseSkip) ?? []).length : 0;
|
|
209
|
+
return Math.max(0, heads.length - skips);
|
|
210
|
+
}
|
|
211
|
+
// An anchor must be a STANDALONE COMMENT LINE, and string/template literals are removed first
|
|
212
|
+
// (adversarial review C2: a raw whole-file match credited a test file's entire executed count to
|
|
213
|
+
// every id merely MENTIONED in it — a live run credited 50 cases to the nonexistent A-SPEC-999 from
|
|
214
|
+
// a fixture string, and injecting one comment into any test file re-credited it to an arbitrary
|
|
215
|
+
// spec). Both the fixture-string and the mid-code-line injection vectors die here.
|
|
216
|
+
const IMPL_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*|#)[^\n]*?@implements\s+(A-SPEC-\d{3,}(?:\.\d+)?)/gm;
|
|
217
|
+
const STRINGS_RE = /'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g;
|
|
218
|
+
/** A-SPEC ids anchored by a file, counting only standalone comment-line markers outside literals. */
|
|
219
|
+
function extractAnchors(source) {
|
|
220
|
+
const noStrings = source.replace(STRINGS_RE, '""');
|
|
221
|
+
return [...new Set([...noStrings.matchAll(IMPL_LINE_RE)].map((m) => m[1]))];
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Scan the repo for TEST files and the A-SPEC ids they @implement. Separate from the CPG scan (which
|
|
225
|
+
* excludes tests), so the test↔spec edges actually exist for scoping. Returns repo-relative POSIX
|
|
226
|
+
* paths → the impacted-spec ids they anchor. Pure-ish (fs read only); resilient to unreadable files.
|
|
227
|
+
*/
|
|
228
|
+
function scanTestAnchors(root) {
|
|
229
|
+
const out = {};
|
|
230
|
+
const walk = (dir) => {
|
|
231
|
+
let entries;
|
|
232
|
+
try {
|
|
233
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
for (const e of entries) {
|
|
239
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist')
|
|
240
|
+
continue;
|
|
241
|
+
const abs = path.join(dir, e.name);
|
|
242
|
+
if (e.isDirectory()) {
|
|
243
|
+
walk(abs);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
247
|
+
if (!(0, cpg_scanner_1.isTestFile)(rel))
|
|
248
|
+
continue;
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const ids = extractAnchors(text);
|
|
257
|
+
if (ids.length)
|
|
258
|
+
out[rel] = ids;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
walk(root);
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
function computeTestScope(impactedSpecsRaw, testAnchors, specs, coverageGaps = [], cfg, extras) {
|
|
265
|
+
const t = { ...DEFAULTS, ...(cfg ?? {}) };
|
|
266
|
+
const impactedSpecs = [...new Set(impactedSpecsRaw.map(stripSpecPrefix))].sort();
|
|
267
|
+
const impacted = new Set(impactedSpecs);
|
|
268
|
+
const changedTestFiles = [...new Set(extras?.changedTestFiles ?? [])].sort();
|
|
269
|
+
const unresolvedFiles = [...new Set(extras?.unresolvedFiles ?? [])].sort();
|
|
270
|
+
const impactedTestFiles = Object.entries(testAnchors)
|
|
271
|
+
.filter(([, ids]) => ids.some((a) => impacted.has(a)))
|
|
272
|
+
.map(([file]) => file)
|
|
273
|
+
.sort();
|
|
274
|
+
// Lane-3 (A-SPEC-130): a changed test file IS the regression scope for its own change — the symbol
|
|
275
|
+
// scan excludes it by design, so it enters at the selection, not the graph.
|
|
276
|
+
const selectedTestFiles = [...new Set([...impactedTestFiles, ...changedTestFiles])].sort();
|
|
277
|
+
// @implements A-SPEC-130
|
|
278
|
+
// Unresolved remainder: changed source the graph cannot see. "Zero impact" derived from an
|
|
279
|
+
// incomplete graph is not evidence of no impact — same epistemic state as C3 below, same answer.
|
|
280
|
+
// Checked FIRST so a contradictory input (empty impact + unresolved present) resolves toward
|
|
281
|
+
// full, never toward "nothing to re-test".
|
|
282
|
+
if (unresolvedFiles.length > 0) {
|
|
283
|
+
const shown = unresolvedFiles.slice(0, 5).join(', ');
|
|
284
|
+
const suffix = unresolvedFiles.length > 5 ? ` (${unresolvedFiles.length} unresolved total)` : '';
|
|
285
|
+
return {
|
|
286
|
+
tier: 'full', impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius: 0,
|
|
287
|
+
hitsFoundational: false, coverageGaps: [...coverageGaps], unresolvedFiles,
|
|
288
|
+
reason: `${unresolvedFiles.length} changed source file(s) resolved to no spec — cannot narrow safely, full regression: ${shown}${suffix}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Tier on the DIRECTLY-impacted A-SPECs (what the changed code implements), NOT the whole impacted
|
|
292
|
+
// set — impactedBy always returns the ancestor chain up to the REQ root, so keying on it made every
|
|
293
|
+
// change "reach a foundational REQ" → always full (verification H4). Downstream blast of the direct
|
|
294
|
+
// A-SPEC(s) is the real breadth signal; only a change that DIRECTLY touches a REQ/H-SPEC (no direct
|
|
295
|
+
// A-SPEC in the set) falls back to the whole set (where a foundational hit legitimately means full).
|
|
296
|
+
const byId = new Map(specs.map((s) => [s.id, s]));
|
|
297
|
+
const directSpecs = impactedSpecs.filter((id) => byId.get(id)?.type === 'A-SPEC');
|
|
298
|
+
const tierSpecs = directSpecs.length > 0 ? directSpecs : impactedSpecs;
|
|
299
|
+
let blastRadius = 0;
|
|
300
|
+
let hitsFoundational = false;
|
|
301
|
+
for (const id of tierSpecs) {
|
|
302
|
+
const br = (0, rtm_check_1.computeBlastRadius)(specs, id); // bare ids now — C1
|
|
303
|
+
blastRadius = Math.max(blastRadius, br.impactedSpecCount);
|
|
304
|
+
hitsFoundational = hitsFoundational || br.hitsFoundational;
|
|
305
|
+
}
|
|
306
|
+
let tier;
|
|
307
|
+
let reason;
|
|
308
|
+
if (impactedSpecs.length === 0) {
|
|
309
|
+
tier = 'unit';
|
|
310
|
+
// Lane-3 keeps this branch honest: with a changed test file in hand, "nothing to re-test" would
|
|
311
|
+
// be false — the changed test itself is the scope (A-SPEC-130).
|
|
312
|
+
reason = changedTestFiles.length > 0
|
|
313
|
+
? `no impacted specs — running ${changedTestFiles.length} changed test file(s)`
|
|
314
|
+
: 'no impacted specs — nothing to re-test';
|
|
315
|
+
}
|
|
316
|
+
else if (hitsFoundational || blastRadius >= t.fullBlast || coverageGaps.length > 0) {
|
|
317
|
+
tier = 'full';
|
|
318
|
+
reason = hitsFoundational ? 'reaches a foundational REQ — full regression'
|
|
319
|
+
: coverageGaps.length > 0 ? `impacted specs with coverage gaps (${coverageGaps.length}) — full regression`
|
|
320
|
+
: `wide downstream blast (${blastRadius}) — full regression`;
|
|
321
|
+
}
|
|
322
|
+
else if (impactedTestFiles.length === 0) {
|
|
323
|
+
// SAFETY (C3): impacted specs but no anchored tests resolved → cannot prove a narrow run is safe.
|
|
324
|
+
tier = 'full';
|
|
325
|
+
reason = 'impacted specs but no anchored tests resolved — cannot narrow safely, full regression';
|
|
326
|
+
}
|
|
327
|
+
else if (blastRadius >= t.partialBlast) {
|
|
328
|
+
tier = 'partial';
|
|
329
|
+
reason = `moderate downstream blast (${blastRadius}) — impacted tests + margin`;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
tier = 'unit';
|
|
333
|
+
reason = `localized impact (blast=${blastRadius}) — run the ${impactedTestFiles.length} impacted test file(s)`;
|
|
334
|
+
}
|
|
335
|
+
return { tier, impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius, hitsFoundational, coverageGaps: [...coverageGaps], reason, unresolvedFiles: [] };
|
|
336
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.blockerClause = exports.BLOCKER_SEP = exports.unsealedParentMessage = exports.placeholderMessage = exports.SUPPLIED_BY_APPROVAL = void 0;
|
|
4
|
+
exports.placeholderSections = placeholderSections;
|
|
5
|
+
exports.parentBlockers = parentBlockers;
|
|
6
|
+
exports.approvalBlockers = approvalBlockers;
|
|
7
|
+
exports.blockerSummary = blockerSummary;
|
|
8
|
+
const validator_1 = require("./validator");
|
|
9
|
+
const breaking_change_1 = require("./breaking-change");
|
|
10
|
+
const spec_digest_1 = require("./spec-digest");
|
|
11
|
+
const spec_types_1 = require("./spec-types");
|
|
12
|
+
const draft_1 = require("../reverse/draft");
|
|
13
|
+
/**
|
|
14
|
+
* What stands between a spec and its approval — computed once, consumed by both the gate and the
|
|
15
|
+
* act of approving.
|
|
16
|
+
*
|
|
17
|
+
* The defect this exists for, measured over 244 governed benchmark runs on 2026-08-13: the gate
|
|
18
|
+
* refused 425 times across 99 distinct wordings, and every one of them stopped at "not approved".
|
|
19
|
+
* The reason was already computable — `spec_approve` derives it from the same specs moments later.
|
|
20
|
+
* Authors who could not see it went looking: 8.5 shell calls per run groping through `.ax/`,
|
|
21
|
+
* correlating with total token spend at r=0.71. Runs that never finished the chain burned 48% of all
|
|
22
|
+
* tokens and scored worse than the ones that did (76% vs 96%).
|
|
23
|
+
*
|
|
24
|
+
* SHARED, NOT COPIED — and adversarial review found the first version sharing only the leaves while
|
|
25
|
+
* the COMPOSITION stayed duplicated: the parent checks lived solely in `spec_approve`, so the gate
|
|
26
|
+
* reported "nothing blocks" for a draft under a draft parent, which is the modal state of top-down
|
|
27
|
+
* authoring. Every predicate that can refuse an approval now lives here, and `spec_approve` calls in
|
|
28
|
+
* rather than restating.
|
|
29
|
+
*/
|
|
30
|
+
/** A fence delimiter: three or more backticks or tildes, optionally indented. */
|
|
31
|
+
const FENCE = /^(\s*)(`{3,}|~{3,})(.*)$/;
|
|
32
|
+
/**
|
|
33
|
+
* Drop fenced blocks before judging a section.
|
|
34
|
+
*
|
|
35
|
+
* `parseSpec`'s splitter is fence-aware; this judge was not, so a section that merely QUOTED a
|
|
36
|
+
* generator's output was read as being one — review demonstrated it on A-SPEC-182's own Behavior
|
|
37
|
+
* section, which documents exactly that shape.
|
|
38
|
+
*
|
|
39
|
+
* MATCHES THE OPENER, per CommonMark: a fence closes only on the same character with a run at least
|
|
40
|
+
* as long, and a closer carries no info string. Two earlier versions were wrong in opposite
|
|
41
|
+
* directions and review caught both:
|
|
42
|
+
* - a regex over the whole body knew only backticks, so a `~~~` quote was refused forever;
|
|
43
|
+
* - a naive line toggle flipped on ANY fence line, so a 3-backtick example nested in a 4-backtick
|
|
44
|
+
* block re-opened mid-quote (false refusal), and an UNTERMINATED opener swallowed the rest of the
|
|
45
|
+
* section — failing OPEN, sealing the very stub this rule exists to catch.
|
|
46
|
+
* An opener with no closer is therefore treated as ordinary prose: the safe direction is to judge
|
|
47
|
+
* text we are unsure about, not to skip it.
|
|
48
|
+
*/
|
|
49
|
+
const stripFences = (s) => {
|
|
50
|
+
const lines = s.split('\n');
|
|
51
|
+
const drop = new Set();
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const open = FENCE.exec(lines[i]);
|
|
54
|
+
if (!open || drop.has(i))
|
|
55
|
+
continue;
|
|
56
|
+
const [, , marker] = open;
|
|
57
|
+
let close = -1;
|
|
58
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
59
|
+
const c = FENCE.exec(lines[j]);
|
|
60
|
+
// A closer is the same character, at least as long, and nothing else on the line.
|
|
61
|
+
if (c && c[2][0] === marker[0] && c[2].length >= marker.length && c[3].trim() === '') {
|
|
62
|
+
close = j;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (close === -1)
|
|
67
|
+
continue; // unterminated: not a fence, judge it as prose
|
|
68
|
+
for (let k = i; k <= close; k++)
|
|
69
|
+
drop.add(k);
|
|
70
|
+
i = close;
|
|
71
|
+
}
|
|
72
|
+
return lines.filter((_, i) => !drop.has(i)).join('\n');
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* A line that is itself a generator's prompt. Anchored per-line, not to the start of the section:
|
|
76
|
+
* `reverse_draft` opens Intent, Objective and Normal Cases with its REVERSE-ENGINEERED note, so the
|
|
77
|
+
* prompt is the third line there. Measured against the real brownfield H-SPEC-100 — a
|
|
78
|
+
* section-anchored rule refused four of its sections and let Intent through, the one section whose
|
|
79
|
+
* emptiness matters most.
|
|
80
|
+
*/
|
|
81
|
+
const TODO_LINE = /^TODO\s*[—-]/m;
|
|
82
|
+
/**
|
|
83
|
+
* Sections that still hold nothing but a generated placeholder.
|
|
84
|
+
*
|
|
85
|
+
* Two generators plant placeholders and both are judged here:
|
|
86
|
+
* - `reverse_draft` writes `TODO — <prompt>` plus the INFERRED marker comment. Both are
|
|
87
|
+
* required together: 11 of this repository's 257 approved specs use the word "TODO" in
|
|
88
|
+
* legitimate prose ("남아 있는 TODO 주석을 제거한다"), and a word search refuses every one.
|
|
89
|
+
* - `spec_create` writes the bare literal `TODO` (`FIELD_PLACEHOLDER`) into each required section.
|
|
90
|
+
* That one is unambiguous on its own — an entire section equal to the string is never prose.
|
|
91
|
+
*/
|
|
92
|
+
function placeholderSections(spec) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const [name, body] of Object.entries(spec.sections ?? {})) {
|
|
95
|
+
const text = String(body ?? '').trim();
|
|
96
|
+
if (text === spec_types_1.FIELD_PLACEHOLDER) {
|
|
97
|
+
out.push(name);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const prose = stripFences(text);
|
|
101
|
+
if (TODO_LINE.test(prose) && prose.includes(draft_1.INFERRED))
|
|
102
|
+
out.push(name);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Codes that `validateSpec` reports but approval itself supplies, so they are not blockers.
|
|
108
|
+
* Exported because `spec_approve` filters by the same set — two hand-maintained lists were how the
|
|
109
|
+
* gate and the act drifted apart in the first place.
|
|
110
|
+
*/
|
|
111
|
+
exports.SUPPLIED_BY_APPROVAL = new Set(['unsealed-approval', 'stale-parent']);
|
|
112
|
+
/**
|
|
113
|
+
* Parent-chain reasons an approval is refused.
|
|
114
|
+
*
|
|
115
|
+
* `validateSpec` deliberately stays silent on both (it `continue`s past a draft or unsealed parent —
|
|
116
|
+
* "other findings' business"), so nothing else computes these. Shared with `spec_approve` so the
|
|
117
|
+
* wording cannot drift.
|
|
118
|
+
*/
|
|
119
|
+
/** The one wording for a spec whose prose is still a generator stub. Shared for the same reason. */
|
|
120
|
+
const placeholderMessage = (sections) => `자리표시자만 남은 절(${sections.join(', ')})이 있습니다 — 승인은 서술을 규범으로 만든다. 채운 뒤 다시 승인하라.`;
|
|
121
|
+
exports.placeholderMessage = placeholderMessage;
|
|
122
|
+
/** The one wording for an unsealed parent. Shared so a second, degraded copy cannot appear. */
|
|
123
|
+
const unsealedParentMessage = (pid) => `parent ${pid} is approved but unsealed — seal parents first (spec_approve ${pid})`;
|
|
124
|
+
exports.unsealedParentMessage = unsealedParentMessage;
|
|
125
|
+
function parentBlockers(spec, resolve) {
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const pid of spec.dependsOn ?? []) {
|
|
128
|
+
const parent = resolve(pid);
|
|
129
|
+
if (!parent)
|
|
130
|
+
continue; // dangling parents are validateSpec's finding
|
|
131
|
+
if (parent.status !== 'approved') {
|
|
132
|
+
out.push(`상위 스펙 ${parent.id}이(가) approved가 아닙니다(현재 ${parent.status})`
|
|
133
|
+
+ ' — 자식은 승인되지 않은 부모 아래에서 승인될 수 없습니다. 부모를 먼저 승인하세요.');
|
|
134
|
+
}
|
|
135
|
+
else if (!(0, spec_digest_1.sealOf)(parent).approvedDigest) {
|
|
136
|
+
out.push((0, exports.unsealedParentMessage)(pid));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Every reason `spec` cannot be approved right now. Empty means nothing is in the way.
|
|
143
|
+
*
|
|
144
|
+
* Order matches `spec_approve`'s: parents, then static validity, then the declaration duty, then
|
|
145
|
+
* placeholders. Pure — it reads the spec objects it is handed and touches no filesystem, because
|
|
146
|
+
* the gate calls it from a PreToolUse hook on every tool call.
|
|
147
|
+
*/
|
|
148
|
+
function approvalBlockers(spec, resolve) {
|
|
149
|
+
const out = parentBlockers(spec, resolve);
|
|
150
|
+
// Validate AS IT WILL BE — approved. Approving is what supplies the status and the seals, so the
|
|
151
|
+
// question is what would still block THIS document once approved.
|
|
152
|
+
const candidate = { ...spec, status: 'approved', frontmatter: { ...spec.frontmatter } };
|
|
153
|
+
delete candidate.frontmatter.approved_digest;
|
|
154
|
+
delete candidate.frontmatter.parent_digests;
|
|
155
|
+
out.push(...(0, validator_1.validateSpec)(candidate, resolve).findings
|
|
156
|
+
.filter((f) => f.level === 'error' && !exports.SUPPLIED_BY_APPROVAL.has(f.code))
|
|
157
|
+
.map((f) => f.message));
|
|
158
|
+
const breaking = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
|
|
159
|
+
if (breaking)
|
|
160
|
+
out.push(breaking);
|
|
161
|
+
const stubs = placeholderSections(spec);
|
|
162
|
+
if (stubs.length > 0) {
|
|
163
|
+
out.push((0, exports.placeholderMessage)(stubs));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/** The separator between one spec's blockers. Shared so nothing re-splits on a guessed delimiter. */
|
|
168
|
+
exports.BLOCKER_SEP = '; ';
|
|
169
|
+
/**
|
|
170
|
+
* One spec's blockers as a labelled clause.
|
|
171
|
+
*
|
|
172
|
+
* Exported so a caller that needs to CLAMP the list works on real blocker boundaries. Review found
|
|
173
|
+
* `tspec-state` re-splitting the joined string on `; ` — a delimiter that also occurs inside
|
|
174
|
+
* author-controlled text (a `depends_on` entry, a section name), which produced a sliced spec id
|
|
175
|
+
* with an unterminated quote and a remainder counting fragments rather than blockers.
|
|
176
|
+
*/
|
|
177
|
+
const blockerClause = (label, blockers) => ` [${label}] 승인까지 남은 것: ${blockers.join(exports.BLOCKER_SEP)}`;
|
|
178
|
+
exports.blockerClause = blockerClause;
|
|
179
|
+
/**
|
|
180
|
+
* The blockers as one line for a refusal message, or `null` when nothing blocks.
|
|
181
|
+
*
|
|
182
|
+
* `specId` labels the list. A refusal can name several specs at once, and review found the
|
|
183
|
+
* unlabelled form reintroduced exactly what REQ-155 forbids: the reader saw two reason-lists run
|
|
184
|
+
* together with no way to tell which spec each belonged to.
|
|
185
|
+
*
|
|
186
|
+
* Blockers are separated by `; ` rather than ` / ` because section names legitimately contain a
|
|
187
|
+
* slash (`Inputs / Outputs`), which made the boundaries unreadable.
|
|
188
|
+
*
|
|
189
|
+
* Never throws: a refusal that says less is recoverable, a hook that dies is not.
|
|
190
|
+
*/
|
|
191
|
+
function blockerSummary(spec, resolve, specId) {
|
|
192
|
+
if (!spec)
|
|
193
|
+
return null;
|
|
194
|
+
try {
|
|
195
|
+
const b = approvalBlockers(spec, resolve);
|
|
196
|
+
if (b.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
const label = specId ?? spec.id;
|
|
199
|
+
return (0, exports.blockerClause)(label, b);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BREAKING_CHANGE_GRADES = void 0;
|
|
4
|
+
exports.checkBreakingChangeDeclared = checkBreakingChangeDeclared;
|
|
5
|
+
exports.breakingChangeDistribution = breakingChangeDistribution;
|
|
6
|
+
/**
|
|
7
|
+
* ADR-013's enforcement device: the obligation to declare a breaking change lives on the ACT of
|
|
8
|
+
* approval, not on the document's static validity.
|
|
9
|
+
*
|
|
10
|
+
* WHY here and not in `validateSpec`'s `requiredFields`, measured rather than argued: all 38 governed
|
|
11
|
+
* A-SPECs in this repository are already approved, so adding the field to the state predicate
|
|
12
|
+
* produced **38 ART-3 violations and `evaluateStop().block === true`** — the harness would have
|
|
13
|
+
* blocked every turn, bricked by the very work meant to strengthen its governance.
|
|
14
|
+
*
|
|
15
|
+
* The move is not a workaround, it is the correct reading. "Is this change breaking?" is not a static
|
|
16
|
+
* property a document either has or lacks; it is a question that must be answered at the moment a
|
|
17
|
+
* change is sealed. Re-sealing means the content changed, which is exactly when the question is due.
|
|
18
|
+
*
|
|
19
|
+
* CONVERGENCE: any spec that changes must be re-approved and therefore acquires the field. A spec
|
|
20
|
+
* that never changes never acquires it — and cannot introduce a breaking change either. That is why
|
|
21
|
+
* no backfill is needed, and why mechanically filling 38 documents with `none` would be the very
|
|
22
|
+
* formulaic answer this device exists to discourage.
|
|
23
|
+
*
|
|
24
|
+
* REMOVAL AFTER APPROVAL is not defended here on purpose: deleting the field changes the body, the
|
|
25
|
+
* `approved_digest` stops matching, and the existing seal machinery reports `post-approval-edit`.
|
|
26
|
+
* Adding a second mechanism for a case the first already covers would be duplication, not depth.
|
|
27
|
+
*/
|
|
28
|
+
/** ADR-013's four tiers, plus `none`. Order is the order the denial message lists them in. */
|
|
29
|
+
exports.BREAKING_CHANGE_GRADES = [
|
|
30
|
+
'none',
|
|
31
|
+
'persisted-artifact',
|
|
32
|
+
'derived-artifact',
|
|
33
|
+
'code-interface',
|
|
34
|
+
'gate-behavior',
|
|
35
|
+
];
|
|
36
|
+
const GRADES = new Set(exports.BREAKING_CHANGE_GRADES);
|
|
37
|
+
/** Splits `grade` or `grade: reason` without discarding a reason that itself contains a colon. */
|
|
38
|
+
function parse(value) {
|
|
39
|
+
const i = value.indexOf(':');
|
|
40
|
+
return i === -1
|
|
41
|
+
? { grade: value.trim(), reason: '' }
|
|
42
|
+
: { grade: value.slice(0, i).trim(), reason: value.slice(i + 1).trim() };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `null` when the spec satisfies ADR-013's declaration duty, otherwise the reason it does not.
|
|
46
|
+
*
|
|
47
|
+
* Only A-SPEC is checked: it is the layer that carries the implementation contract (`Files to
|
|
48
|
+
* Touch`), so it is the layer where a change becomes breaking. Widening this to every spec type
|
|
49
|
+
* would collect answers from documents that touch no artefact.
|
|
50
|
+
*/
|
|
51
|
+
function checkBreakingChangeDeclared(spec) {
|
|
52
|
+
if (spec.type !== 'A-SPEC')
|
|
53
|
+
return null;
|
|
54
|
+
const raw = spec.frontmatter?.breaking_change;
|
|
55
|
+
// The denial names ADR-013 and lists the grades: a refusal that does not say what to do next is an
|
|
56
|
+
// obstacle rather than a gate.
|
|
57
|
+
const help = `허용 값: ${exports.BREAKING_CHANGE_GRADES.join(' | ')} (none 외에는 "<등급>: <사유>" 형태). 근거: ADR-013.`;
|
|
58
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
59
|
+
return `A-SPEC ${spec.id}에 breaking_change 선언이 없습니다 — 파괴적 변경 여부를 답하지 않고는 승인할 수 없습니다. ${help}`;
|
|
60
|
+
}
|
|
61
|
+
const { grade, reason } = parse(raw);
|
|
62
|
+
if (!GRADES.has(grade)) {
|
|
63
|
+
return `A-SPEC ${spec.id}의 breaking_change 등급 "${grade}"은(는) 허용되지 않습니다. ${help}`;
|
|
64
|
+
}
|
|
65
|
+
// A grade with no reason is a label, not a declaration — the reader learns that something broke
|
|
66
|
+
// but not what, which is the part that would have helped them.
|
|
67
|
+
if (grade !== 'none' && reason === '') {
|
|
68
|
+
return `A-SPEC ${spec.id}의 breaking_change가 "${grade}"인데 사유가 없습니다 — 등급만으로는 무엇이 깨지는지 알 수 없습니다. ${help}`;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Grade counts across the governed A-SPECs, with `(unset)` for those that have not been re-approved
|
|
74
|
+
* since ADR-013. A REPORT, never an assertion: "all `none` must fail" would fire falsely during a
|
|
75
|
+
* period when every change genuinely is non-breaking. ADR-013 recorded formulaic-`none` as a revisit
|
|
76
|
+
* trigger for a human to judge, and this surfaces the distribution that judgement needs — including
|
|
77
|
+
* how far the corpus has converged.
|
|
78
|
+
*/
|
|
79
|
+
function breakingChangeDistribution(specs) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const s of specs) {
|
|
82
|
+
if (s.type !== 'A-SPEC')
|
|
83
|
+
continue;
|
|
84
|
+
const raw = s.frontmatter?.breaking_change;
|
|
85
|
+
const key = typeof raw === 'string' && raw.trim() !== '' ? parse(raw).grade : '(unset)';
|
|
86
|
+
out[key] = (out[key] ?? 0) + 1;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|