hstack 0.7.1 → 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 +271 -0
- package/README.md +39 -13
- package/VERSION +1 -1
- package/dist/commands/doctor.js +51 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/update.js +8 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +167 -0
- package/dist/lib/descriptions.js.map +1 -0
- package/dist/lib/diff.js +1 -1
- package/dist/lib/git.js +16 -0
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/wire.js +108 -4
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +17 -2
- package/dist/manifest.js.map +1 -1
- package/package.json +3 -1
- package/template/.claude/agents/adversarial-reviewer.md +16 -64
- package/template/.claude/agents/app-architect.md +12 -49
- package/template/.claude/agents/data-architect.md +13 -51
- package/template/.claude/agents/data-specialist.md +5 -50
- package/template/.claude/agents/implementer.md +8 -65
- package/template/.claude/agents/kernel-fit-analyst.md +7 -68
- package/template/.claude/agents/planner.md +7 -42
- package/template/.claude/agents/product-discovery.md +12 -48
- package/template/.claude/agents/product-manager.md +8 -43
- package/template/.claude/agents/researcher.md +5 -41
- package/template/.claude/agents/security-reviewer.md +19 -54
- package/template/.claude/agents/spec-author.md +18 -52
- package/template/.claude/agents/stack-architect.md +14 -43
- package/template/.claude/agents/test-strategist.md +16 -57
- package/template/.claude/agents/ui-ux-briefer.md +6 -36
- package/template/.claude/agents/verifier.md +13 -45
- package/template/.claude/skills/hstack-adr-new/SKILL.md +6 -33
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +31 -52
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +157 -0
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +2 -29
- package/template/.claude/skills/hstack-branch/SKILL.md +4 -31
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +10 -37
- package/template/.claude/skills/hstack-change-new/SKILL.md +4 -31
- package/template/.claude/skills/hstack-change-plan/SKILL.md +21 -32
- package/template/.claude/skills/hstack-commit/SKILL.md +7 -35
- package/template/.claude/skills/hstack-configure/SKILL.md +7 -34
- package/template/.claude/skills/hstack-coord/SKILL.md +3 -39
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +4 -30
- package/template/.claude/skills/hstack-data-review/SKILL.md +3 -42
- package/template/.claude/skills/hstack-finalize/SKILL.md +30 -49
- package/template/.claude/skills/hstack-flag/SKILL.md +9 -48
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +9 -36
- package/template/.claude/skills/hstack-help/SKILL.md +11 -37
- package/template/.claude/skills/hstack-implement/SKILL.md +28 -58
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +7 -46
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +5 -60
- package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +42 -0
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +12 -50
- package/template/.claude/skills/hstack-module-spec/SKILL.md +5 -32
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +5 -31
- package/template/.claude/skills/hstack-research/SKILL.md +3 -33
- package/template/.claude/skills/hstack-scaffold/SKILL.md +2 -29
- package/template/.claude/skills/hstack-security-review/SKILL.md +5 -43
- package/template/.claude/skills/hstack-ship/SKILL.md +43 -53
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +3 -30
- package/template/.claude/skills/hstack-story-draft/SKILL.md +6 -33
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +4 -31
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +9 -44
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +10 -37
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +8 -35
- package/template/.claude/skills/hstack-telemetry/SKILL.md +5 -30
- package/template/.claude/skills/hstack-test-plan/SKILL.md +23 -46
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +3 -30
- package/template/.claude/skills/hstack-verify/SKILL.md +26 -48
- package/template/KERNEL.md +410 -0
- package/template/scripts/compute-merge-readiness.mjs +780 -0
- package/template/scripts/run-gates.sh +388 -0
- package/template/scripts/telemetry/insights/kernel_fit.py +1 -1
- package/template/scripts/telemetry/insights/token_economics.py +181 -8
- package/template/scripts/telemetry/parsers/sidecars.py +61 -0
- package/template/scripts/telemetry/parsers/transcripts.py +135 -22
- package/template/scripts/telemetry/render.py +68 -3
- package/template/scripts/telemetry/report.py +16 -4
- package/template/scripts/telemetry/run_kernel_fit.py +6 -2
- package/template/scripts/telemetry/session_id.py +139 -0
- package/template/scripts/validate-spec.mjs +3303 -0
- package/template/templates/adr.md +7 -0
- package/template/templates/adversarial-review.md +5 -5
- package/template/templates/ci-cd.md +14 -0
- package/template/templates/coord-message.md +3 -2
- package/template/templates/data-architecture.md +3 -6
- package/template/templates/kernel-fit-finding.md +2 -2
- package/template/templates/kernel-fit-flag.md +2 -2
- package/template/templates/plan.md +4 -0
- package/template/templates/product-brief.md +2 -2
- package/template/templates/roadmap.md +41 -0
- package/template/templates/security-review.md +1 -1
- package/template/templates/telemetry-sidecar.md +56 -13
- package/template/templates/test-plan.md +1 -1
- package/template/CLAUDE.md +0 -443
- package/template/templates/mvp-scope.md +0 -34
|
@@ -0,0 +1,3303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hstack artifact validator — the mechanical contract check every Skill runs
|
|
4
|
+
* after a direct write, and the thing `{{TODO-SCRIPT: validate-spec.ts}}` stood
|
|
5
|
+
* in for since ADR-0001.
|
|
6
|
+
*
|
|
7
|
+
* node hstack/scripts/validate-spec.mjs # every artifact under hstack/
|
|
8
|
+
* node hstack/scripts/validate-spec.mjs <path> [<path>...] # named artifacts
|
|
9
|
+
* node hstack/scripts/validate-spec.mjs --json # machine-readable
|
|
10
|
+
* node hstack/scripts/validate-spec.mjs --strict # warnings fail too
|
|
11
|
+
* node hstack/scripts/validate-spec.mjs --rules # print the registry
|
|
12
|
+
*
|
|
13
|
+
* Exit codes: 0 clean, 1 findings, 2 usage/environment error.
|
|
14
|
+
*
|
|
15
|
+
* ## Why .mjs and not .ts
|
|
16
|
+
*
|
|
17
|
+
* Consuming repos have no `node_modules` for hstack — the framework is copied
|
|
18
|
+
* files. A `.ts` entrypoint would impose a runtime on every consumer: node
|
|
19
|
+
* >= 22.6 with `--experimental-strip-types`, or `npx tsx` (network + install).
|
|
20
|
+
* Plain ESM runs on the node the consumer already has (hstack installs via
|
|
21
|
+
* npx; package.json declares `engines.node >= 18`), needs no build step, and
|
|
22
|
+
* stays importable from `src/commands/doctor.ts` and a future CI gate. The
|
|
23
|
+
* dependency-free precedent is `scripts/telemetry/` and `scripts/coord/`.
|
|
24
|
+
*
|
|
25
|
+
* ## The registry is the inventory
|
|
26
|
+
*
|
|
27
|
+
* `RULES` carries one entry per mechanizable rule id named in the repo, and
|
|
28
|
+
* `DEFERRED_RULES` names every rule that is NOT mechanized in v1 together with
|
|
29
|
+
* the reason. Nothing disappears: a rule is either checked here or listed as
|
|
30
|
+
* deferred with a stated cause. `template/KERNEL.md` and the templates are the
|
|
31
|
+
* authority for what each id means; where a repo source states no rule text,
|
|
32
|
+
* the entry carries `inferred: true` and says so.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
36
|
+
import { join, resolve, relative, dirname, basename, sep } from "node:path";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// 1. Minimal YAML frontmatter parser
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
//
|
|
42
|
+
// hstack frontmatter is a deliberately small YAML subset: scalars, `null`,
|
|
43
|
+
// booleans, integers, flow and block sequences, one or two levels of nested
|
|
44
|
+
// map, and folded block scalars. That subset is worth ~150 lines; a YAML
|
|
45
|
+
// dependency is worth a node_modules tree in every consuming repo.
|
|
46
|
+
|
|
47
|
+
/** Strip a trailing `# comment`, respecting quotes. */
|
|
48
|
+
function stripComment(line) {
|
|
49
|
+
let inSingle = false;
|
|
50
|
+
let inDouble = false;
|
|
51
|
+
for (let i = 0; i < line.length; i++) {
|
|
52
|
+
const c = line[i];
|
|
53
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
54
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
55
|
+
else if (c === "#" && !inSingle && !inDouble) {
|
|
56
|
+
if (i === 0 || /\s/.test(line[i - 1])) return line.slice(0, i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return line;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Split a flow collection body on top-level commas. */
|
|
63
|
+
function splitFlow(body) {
|
|
64
|
+
const out = [];
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let inSingle = false;
|
|
67
|
+
let inDouble = false;
|
|
68
|
+
let cur = "";
|
|
69
|
+
for (const c of body) {
|
|
70
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
71
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
72
|
+
if (!inSingle && !inDouble) {
|
|
73
|
+
if (c === "[" || c === "{") depth++;
|
|
74
|
+
else if (c === "]" || c === "}") depth--;
|
|
75
|
+
else if (c === "," && depth === 0) {
|
|
76
|
+
out.push(cur);
|
|
77
|
+
cur = "";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
cur += c;
|
|
82
|
+
}
|
|
83
|
+
if (cur.trim() !== "") out.push(cur);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const BLOCK_SCALAR = /^[|>][-+]?$/;
|
|
88
|
+
|
|
89
|
+
function parseScalar(raw) {
|
|
90
|
+
const s = raw.trim();
|
|
91
|
+
if (s === "") return null;
|
|
92
|
+
if (s === "null" || s === "~" || s === "Null" || s === "NULL") return null;
|
|
93
|
+
if (s === "true" || s === "True" || s === "TRUE") return true;
|
|
94
|
+
if (s === "false" || s === "False" || s === "FALSE") return false;
|
|
95
|
+
if (/^-?\d+$/.test(s)) return Number(s);
|
|
96
|
+
if (/^-?\d+\.\d+$/.test(s)) return Number(s);
|
|
97
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
98
|
+
const body = s.slice(1, -1).trim();
|
|
99
|
+
return body === "" ? [] : splitFlow(body).map((x) => parseScalar(x));
|
|
100
|
+
}
|
|
101
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
102
|
+
const body = s.slice(1, -1).trim();
|
|
103
|
+
const out = {};
|
|
104
|
+
if (body === "") return out;
|
|
105
|
+
for (const part of splitFlow(body)) {
|
|
106
|
+
const idx = part.indexOf(":");
|
|
107
|
+
if (idx === -1) {
|
|
108
|
+
out[part.trim()] = null;
|
|
109
|
+
} else {
|
|
110
|
+
out[part.slice(0, idx).trim()] = parseScalar(part.slice(idx + 1));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
(s.startsWith('"') && s.endsWith('"') && s.length > 1) ||
|
|
117
|
+
(s.startsWith("'") && s.endsWith("'") && s.length > 1)
|
|
118
|
+
) {
|
|
119
|
+
return s.slice(1, -1);
|
|
120
|
+
}
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse the YAML frontmatter block of a markdown file.
|
|
126
|
+
*
|
|
127
|
+
* Returns `{ found, data, keyLines, endLine, error }`. `keyLines` maps a
|
|
128
|
+
* top-level key to its 1-indexed source line so findings can point at it.
|
|
129
|
+
* `rawScalars` keeps the pre-coercion text of top-level scalars — FM-01 needs
|
|
130
|
+
* it to tell a YAML array from a comma-separated string.
|
|
131
|
+
*/
|
|
132
|
+
export function parseFrontmatter(text) {
|
|
133
|
+
const allLines = text.split("\n");
|
|
134
|
+
if (allLines[0]?.trim() !== "---") {
|
|
135
|
+
return { found: false, data: {}, keyLines: {}, rawScalars: {}, endLine: 0 };
|
|
136
|
+
}
|
|
137
|
+
let end = -1;
|
|
138
|
+
for (let i = 1; i < allLines.length; i++) {
|
|
139
|
+
if (allLines[i].trim() === "---" || allLines[i].trim() === "...") {
|
|
140
|
+
end = i;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (end === -1) {
|
|
145
|
+
return {
|
|
146
|
+
found: false,
|
|
147
|
+
data: {},
|
|
148
|
+
keyLines: {},
|
|
149
|
+
rawScalars: {},
|
|
150
|
+
endLine: 0,
|
|
151
|
+
error: "frontmatter opened with `---` but never closed",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Significant lines only, with indent and source line number retained.
|
|
156
|
+
const lines = [];
|
|
157
|
+
for (let i = 1; i < end; i++) {
|
|
158
|
+
const raw = allLines[i];
|
|
159
|
+
const noComment = stripComment(raw).replace(/\s+$/, "");
|
|
160
|
+
if (noComment.trim() === "") continue;
|
|
161
|
+
lines.push({
|
|
162
|
+
text: noComment,
|
|
163
|
+
indent: noComment.length - noComment.trimStart().length,
|
|
164
|
+
n: i + 1,
|
|
165
|
+
raw,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const keyLines = {};
|
|
170
|
+
const rawScalars = {};
|
|
171
|
+
const [data] = parseBlock(lines, 0, lines.length > 0 ? lines[0].indent : 0, {
|
|
172
|
+
keyLines,
|
|
173
|
+
rawScalars,
|
|
174
|
+
depth: 0,
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
found: true,
|
|
178
|
+
data: data ?? {},
|
|
179
|
+
keyLines,
|
|
180
|
+
rawScalars,
|
|
181
|
+
endLine: end + 1,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Parse a mapping or sequence starting at `i` with the given indent. */
|
|
186
|
+
function parseBlock(lines, i, indent, ctx) {
|
|
187
|
+
if (i >= lines.length) return [null, i];
|
|
188
|
+
const head = lines[i].text.trim();
|
|
189
|
+
if (head.startsWith("[") || head.startsWith("{")) {
|
|
190
|
+
// A flow collection written across several lines under its key.
|
|
191
|
+
const [text, ni] = collectFlow(lines, i);
|
|
192
|
+
return [parseScalar(text), ni];
|
|
193
|
+
}
|
|
194
|
+
if (lines[i].text.trimStart().startsWith("- ") || lines[i].text.trim() === "-") {
|
|
195
|
+
return parseSequence(lines, i, indent, ctx);
|
|
196
|
+
}
|
|
197
|
+
return parseMapping(lines, i, indent, ctx);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** True when a flow collection opened in `s` is still unclosed. */
|
|
201
|
+
function flowUnbalanced(s) {
|
|
202
|
+
let depth = 0;
|
|
203
|
+
let inSingle = false;
|
|
204
|
+
let inDouble = false;
|
|
205
|
+
for (const c of s) {
|
|
206
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
207
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
208
|
+
else if (!inSingle && !inDouble) {
|
|
209
|
+
if (c === "[" || c === "{") depth++;
|
|
210
|
+
else if (c === "]" || c === "}") depth--;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return depth > 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Consume a flow collection that spans several lines, returning the joined
|
|
218
|
+
* text and the index after it. Real artifacts write long `refs:` and
|
|
219
|
+
* `steps-completed:` arrays this way.
|
|
220
|
+
*/
|
|
221
|
+
function collectFlow(lines, i) {
|
|
222
|
+
let text = lines[i].text.trim();
|
|
223
|
+
i++;
|
|
224
|
+
while (flowUnbalanced(text) && i < lines.length) {
|
|
225
|
+
text += " " + lines[i].text.trim();
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
// A trailing comma before the closer is legal in the wild and harmless here.
|
|
229
|
+
return [text.replace(/,\s*([\]}])/g, "$1"), i];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseMapping(lines, i, indent, ctx) {
|
|
233
|
+
const out = {};
|
|
234
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
235
|
+
const line = lines[i];
|
|
236
|
+
const body = line.text.trimStart();
|
|
237
|
+
const colon = findKeyColon(body);
|
|
238
|
+
if (colon === -1) {
|
|
239
|
+
// Not a `key: value` line at this level — stop rather than guess.
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
const key = body.slice(0, colon).trim().replace(/^["']|["']$/g, "");
|
|
243
|
+
const rest = body.slice(colon + 1).trim();
|
|
244
|
+
if (ctx.depth === 0) {
|
|
245
|
+
ctx.keyLines[key] = line.n;
|
|
246
|
+
if (rest !== "") ctx.rawScalars[key] = rest;
|
|
247
|
+
}
|
|
248
|
+
i++;
|
|
249
|
+
if (rest === "") {
|
|
250
|
+
// Nested block, a same-indent sequence, or an explicit empty value.
|
|
251
|
+
const next = lines[i];
|
|
252
|
+
if (next && next.indent > indent) {
|
|
253
|
+
const [val, ni] = parseBlock(lines, i, next.indent, {
|
|
254
|
+
...ctx,
|
|
255
|
+
depth: ctx.depth + 1,
|
|
256
|
+
});
|
|
257
|
+
out[key] = val;
|
|
258
|
+
i = ni;
|
|
259
|
+
} else if (
|
|
260
|
+
next &&
|
|
261
|
+
next.indent === indent &&
|
|
262
|
+
(next.text.trimStart().startsWith("- ") || next.text.trim() === "-")
|
|
263
|
+
) {
|
|
264
|
+
const [val, ni] = parseSequence(lines, i, indent, {
|
|
265
|
+
...ctx,
|
|
266
|
+
depth: ctx.depth + 1,
|
|
267
|
+
});
|
|
268
|
+
out[key] = val;
|
|
269
|
+
i = ni;
|
|
270
|
+
} else {
|
|
271
|
+
out[key] = null;
|
|
272
|
+
}
|
|
273
|
+
} else if (BLOCK_SCALAR.test(rest)) {
|
|
274
|
+
const parts = [];
|
|
275
|
+
while (i < lines.length && lines[i].indent > indent) {
|
|
276
|
+
parts.push(lines[i].text.trim());
|
|
277
|
+
i++;
|
|
278
|
+
}
|
|
279
|
+
out[key] = parts.join(" ");
|
|
280
|
+
} else if (flowUnbalanced(rest)) {
|
|
281
|
+
const [text, ni] = collectFlow([{ text: rest }, ...lines.slice(i)], 0);
|
|
282
|
+
out[key] = parseScalar(text);
|
|
283
|
+
i += ni - 1;
|
|
284
|
+
} else {
|
|
285
|
+
out[key] = parseScalar(rest);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return [out, i];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseSequence(lines, i, indent, ctx) {
|
|
292
|
+
const out = [];
|
|
293
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
294
|
+
const body = lines[i].text.trimStart();
|
|
295
|
+
if (!body.startsWith("- ") && body !== "-") break;
|
|
296
|
+
const rest = body === "-" ? "" : body.slice(2).trim();
|
|
297
|
+
i++;
|
|
298
|
+
const colon = rest === "" ? -1 : findKeyColon(rest);
|
|
299
|
+
if (rest === "") {
|
|
300
|
+
const next = lines[i];
|
|
301
|
+
if (next && next.indent > indent) {
|
|
302
|
+
const [val, ni] = parseBlock(lines, i, next.indent, {
|
|
303
|
+
...ctx,
|
|
304
|
+
depth: ctx.depth + 1,
|
|
305
|
+
});
|
|
306
|
+
out.push(val);
|
|
307
|
+
i = ni;
|
|
308
|
+
} else {
|
|
309
|
+
out.push(null);
|
|
310
|
+
}
|
|
311
|
+
} else if (colon !== -1 && !rest.startsWith("{") && !rest.startsWith("[")) {
|
|
312
|
+
// `- key: value` — an inline map item, possibly with continuation lines.
|
|
313
|
+
const itemIndent = lines[i - 1].indent + 2;
|
|
314
|
+
const synthetic = [
|
|
315
|
+
{ text: " ".repeat(itemIndent) + rest, indent: itemIndent, n: lines[i - 1].n },
|
|
316
|
+
];
|
|
317
|
+
while (i < lines.length && lines[i].indent >= itemIndent) {
|
|
318
|
+
synthetic.push(lines[i]);
|
|
319
|
+
i++;
|
|
320
|
+
}
|
|
321
|
+
const [val] = parseMapping(synthetic, 0, itemIndent, {
|
|
322
|
+
...ctx,
|
|
323
|
+
depth: ctx.depth + 1,
|
|
324
|
+
});
|
|
325
|
+
out.push(val);
|
|
326
|
+
} else {
|
|
327
|
+
out.push(parseScalar(rest));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return [out, i];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Index of the `key:` colon, skipping colons inside quotes or flow braces. */
|
|
334
|
+
function findKeyColon(s) {
|
|
335
|
+
let inSingle = false;
|
|
336
|
+
let inDouble = false;
|
|
337
|
+
let depth = 0;
|
|
338
|
+
for (let i = 0; i < s.length; i++) {
|
|
339
|
+
const c = s[i];
|
|
340
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
341
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
342
|
+
else if (!inSingle && !inDouble) {
|
|
343
|
+
if (c === "[" || c === "{") depth++;
|
|
344
|
+
else if (c === "]" || c === "}") depth--;
|
|
345
|
+
else if (c === ":" && depth === 0) {
|
|
346
|
+
if (i + 1 >= s.length || /\s/.test(s[i + 1])) return i;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return -1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// 2. Markdown body / section parser
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Split a markdown body into sections keyed by heading text.
|
|
359
|
+
* Fenced code blocks are skipped so a `## ` inside a fence is not a heading.
|
|
360
|
+
*/
|
|
361
|
+
export function parseSections(body, startLine = 1) {
|
|
362
|
+
const lines = body.split("\n");
|
|
363
|
+
const sections = [];
|
|
364
|
+
let fence = null;
|
|
365
|
+
let current = null;
|
|
366
|
+
for (let i = 0; i < lines.length; i++) {
|
|
367
|
+
const line = lines[i];
|
|
368
|
+
const fenceMatch = /^\s*(```+|~~~+)/.exec(line);
|
|
369
|
+
if (fenceMatch) {
|
|
370
|
+
if (fence === null) fence = fenceMatch[1][0];
|
|
371
|
+
else if (line.trimStart().startsWith(fence)) fence = null;
|
|
372
|
+
}
|
|
373
|
+
const h = fence === null ? /^(#{1,6})\s+(.*?)\s*$/.exec(line) : null;
|
|
374
|
+
if (h) {
|
|
375
|
+
current = {
|
|
376
|
+
level: h[1].length,
|
|
377
|
+
title: h[2].trim(),
|
|
378
|
+
line: startLine + i,
|
|
379
|
+
lines: [],
|
|
380
|
+
};
|
|
381
|
+
sections.push(current);
|
|
382
|
+
} else if (current) {
|
|
383
|
+
current.lines.push(line);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// `deep` is the section plus everything under its subsections. A parent
|
|
387
|
+
// heading whose content all lives in `###` children is filled in, not empty.
|
|
388
|
+
for (let i = 0; i < sections.length; i++) {
|
|
389
|
+
const s = sections[i];
|
|
390
|
+
s.deep = [...s.lines];
|
|
391
|
+
for (let j = i + 1; j < sections.length && sections[j].level > s.level; j++) {
|
|
392
|
+
s.deep.push("#".repeat(sections[j].level) + " " + sections[j].title);
|
|
393
|
+
s.deep.push(...sections[j].lines);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return sections;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Find a section by heading text. Ignores a leading ordinal (`2. `,
|
|
401
|
+
* `Section 2 — `) and inline markup, so a repo that numbers its headings still
|
|
402
|
+
* matches the template's canonical name.
|
|
403
|
+
*/
|
|
404
|
+
export function findSection(sections, title, level = 2) {
|
|
405
|
+
const want = normalizeHeading(title);
|
|
406
|
+
return (
|
|
407
|
+
sections.find((s) => s.level === level && normalizeHeading(s.title) === want) ??
|
|
408
|
+
null
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function normalizeHeading(t) {
|
|
413
|
+
return t
|
|
414
|
+
.replace(/[`_*]/g, "")
|
|
415
|
+
.replace(/^\s*(section\s+)?\d+\s*[.:\-—–]*\s*/i, "")
|
|
416
|
+
.replace(/[–—]/g, "-")
|
|
417
|
+
.replace(/\s+/g, " ")
|
|
418
|
+
.trim()
|
|
419
|
+
.toLowerCase();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The section's full text, including its subsections. Used by containment checks. */
|
|
423
|
+
export function sectionText(section) {
|
|
424
|
+
return section ? (section.deep ?? section.lines).join("\n") : "";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Lines in a section that carry real content.
|
|
429
|
+
*
|
|
430
|
+
* Templates ship with italic guidance (`_..._`), bare `-` bullet stubs, and
|
|
431
|
+
* empty table rows. A section that still holds only those is "not filled in",
|
|
432
|
+
* which is exactly what the non-empty rules are asking about. This is a
|
|
433
|
+
* heuristic and is documented as one — it cannot tell thin prose from good.
|
|
434
|
+
*/
|
|
435
|
+
export function substantiveLines(section) {
|
|
436
|
+
if (!section) return [];
|
|
437
|
+
return (section.deep ?? section.lines).filter((l) => {
|
|
438
|
+
const t = l.trim();
|
|
439
|
+
if (t === "") return false;
|
|
440
|
+
if (t === "-" || t === "*" || t === "- " ) return false;
|
|
441
|
+
if (/^_.*_$/.test(t)) return false; // whole-line italic guidance
|
|
442
|
+
if (/^<!--/.test(t)) return false;
|
|
443
|
+
if (/^\|[\s|:-]*\|$/.test(t)) return false; // empty or separator table row
|
|
444
|
+
if (/^#{1,6}\s/.test(t)) return false; // nested heading, counted on its own
|
|
445
|
+
return true;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Bullet items in a section that carry content. */
|
|
450
|
+
export function bulletItems(section) {
|
|
451
|
+
return substantiveLines(section).filter((l) => /^\s*[-*]\s+\S/.test(l));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// 3. Per-type schemas — derived from template/templates/*.md
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
459
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
460
|
+
|
|
461
|
+
/** The shared floor from KERNEL.md § Frontmatter contract. */
|
|
462
|
+
const FLOOR_FIELDS = ["id", "type", "status", "owner", "created", "updated"];
|
|
463
|
+
|
|
464
|
+
/** Fields that must be YAML arrays wherever they appear. */
|
|
465
|
+
const ARRAY_FIELDS = new Set([
|
|
466
|
+
"surfaces",
|
|
467
|
+
"user-stories",
|
|
468
|
+
"related-adrs",
|
|
469
|
+
"creates-tech-debt",
|
|
470
|
+
"resolves-tech-debt",
|
|
471
|
+
"children",
|
|
472
|
+
"revisits-change",
|
|
473
|
+
"enables",
|
|
474
|
+
"enabled-by",
|
|
475
|
+
"in-scope",
|
|
476
|
+
"out-of-scope",
|
|
477
|
+
"paths",
|
|
478
|
+
"related-modules",
|
|
479
|
+
"related-change-specs",
|
|
480
|
+
"promoted-from-kernel-fit",
|
|
481
|
+
"linked-change-specs",
|
|
482
|
+
"reused-components",
|
|
483
|
+
"new-components",
|
|
484
|
+
"figma-frame-urls",
|
|
485
|
+
"tenant-isolation-tests",
|
|
486
|
+
"invariants-mapped",
|
|
487
|
+
"steps-completed",
|
|
488
|
+
"findings",
|
|
489
|
+
"evidence-rows",
|
|
490
|
+
"related-findings",
|
|
491
|
+
"refs",
|
|
492
|
+
"derived-from",
|
|
493
|
+
"downstream",
|
|
494
|
+
"surfaces-covered",
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
/** Date-typed fields, validated as ISO 8601 calendar dates. */
|
|
498
|
+
const DATE_FIELDS = new Set([
|
|
499
|
+
"created",
|
|
500
|
+
"updated",
|
|
501
|
+
"decision-date",
|
|
502
|
+
"last-refreshed",
|
|
503
|
+
"reviewed-on",
|
|
504
|
+
"reviewed-quarterly-on",
|
|
505
|
+
"last-quarterly-review",
|
|
506
|
+
"schema-snapshot-date",
|
|
507
|
+
"resolution-attempted-at",
|
|
508
|
+
"stale-verified-at",
|
|
509
|
+
"expires",
|
|
510
|
+
]);
|
|
511
|
+
|
|
512
|
+
const SURFACES = ["ui", "api", "db", "infra", "agent"];
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Per-type status enums and type-local enum fields. Case-sensitive by kernel
|
|
516
|
+
* rule ("controlled enums are case-sensitive").
|
|
517
|
+
*/
|
|
518
|
+
export const TYPE_SCHEMAS = {
|
|
519
|
+
"change-spec": {
|
|
520
|
+
status: [
|
|
521
|
+
"draft",
|
|
522
|
+
"ready-to-plan",
|
|
523
|
+
"ready-for-implementation",
|
|
524
|
+
"ready-for-review",
|
|
525
|
+
"ready-to-ship",
|
|
526
|
+
"shipped",
|
|
527
|
+
"archived",
|
|
528
|
+
],
|
|
529
|
+
enums: {},
|
|
530
|
+
idShape: "kebab",
|
|
531
|
+
},
|
|
532
|
+
"module-spec": {
|
|
533
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
534
|
+
idShape: "kebab",
|
|
535
|
+
},
|
|
536
|
+
"tech-debt": {
|
|
537
|
+
status: [
|
|
538
|
+
"open",
|
|
539
|
+
"in-progress",
|
|
540
|
+
"resolved",
|
|
541
|
+
"wontfix",
|
|
542
|
+
"stale-no-longer-reproducible",
|
|
543
|
+
"archived",
|
|
544
|
+
],
|
|
545
|
+
enums: {
|
|
546
|
+
severity: ["critical", "high", "medium", "low"],
|
|
547
|
+
cost: ["small", "medium", "large"],
|
|
548
|
+
"fix-sketch-effort": ["small", "medium", "large"],
|
|
549
|
+
},
|
|
550
|
+
idShape: "td",
|
|
551
|
+
},
|
|
552
|
+
adr: {
|
|
553
|
+
status: ["proposed", "accepted", "deprecated", "superseded"],
|
|
554
|
+
idShape: "adr",
|
|
555
|
+
},
|
|
556
|
+
"test-plan": {
|
|
557
|
+
status: [
|
|
558
|
+
"draft",
|
|
559
|
+
"in-progress",
|
|
560
|
+
"passed",
|
|
561
|
+
"concerns-acknowledged",
|
|
562
|
+
"failed",
|
|
563
|
+
"superseded",
|
|
564
|
+
],
|
|
565
|
+
enums: { "scoring-mode": ["llm-strategized", "executed"] },
|
|
566
|
+
idShape: "kebab",
|
|
567
|
+
},
|
|
568
|
+
"security-review": {
|
|
569
|
+
status: [
|
|
570
|
+
"draft",
|
|
571
|
+
"in-progress",
|
|
572
|
+
"passed",
|
|
573
|
+
"concerns-acknowledged",
|
|
574
|
+
"failed",
|
|
575
|
+
"superseded",
|
|
576
|
+
],
|
|
577
|
+
enums: { "scoring-mode": ["llm-scored", "executed"] },
|
|
578
|
+
idShape: "kebab",
|
|
579
|
+
},
|
|
580
|
+
"data-review": {
|
|
581
|
+
status: [
|
|
582
|
+
"draft",
|
|
583
|
+
"in-progress",
|
|
584
|
+
"passed",
|
|
585
|
+
"concerns-acknowledged",
|
|
586
|
+
"failed",
|
|
587
|
+
"superseded",
|
|
588
|
+
],
|
|
589
|
+
enums: {
|
|
590
|
+
"migration-safety": ["safe", "needs-backfill", "risky"],
|
|
591
|
+
"rag-impact": ["scoped", "broadened", "narrowed", "none"],
|
|
592
|
+
},
|
|
593
|
+
idShape: "kebab",
|
|
594
|
+
},
|
|
595
|
+
plan: {
|
|
596
|
+
status: ["draft", "ready", "in-progress", "completed", "archived"],
|
|
597
|
+
idShape: "kebab",
|
|
598
|
+
},
|
|
599
|
+
verification: {
|
|
600
|
+
status: ["draft", "ran", "passed", "failed", "superseded"],
|
|
601
|
+
idShape: "kebab",
|
|
602
|
+
},
|
|
603
|
+
"adversarial-review": {
|
|
604
|
+
status: [
|
|
605
|
+
"draft",
|
|
606
|
+
"in-progress",
|
|
607
|
+
"findings-open",
|
|
608
|
+
"findings-resolved",
|
|
609
|
+
"superseded",
|
|
610
|
+
],
|
|
611
|
+
idShape: "kebab",
|
|
612
|
+
},
|
|
613
|
+
"ui-brief": {
|
|
614
|
+
status: ["draft", "drafted", "superseded"],
|
|
615
|
+
idShape: "kebab",
|
|
616
|
+
},
|
|
617
|
+
"figma-handoff": {
|
|
618
|
+
status: ["draft", "ready", "superseded"],
|
|
619
|
+
idShape: "kebab",
|
|
620
|
+
},
|
|
621
|
+
story: {
|
|
622
|
+
status: ["drafted", "ready", "in-flight", "shipped", "archived"],
|
|
623
|
+
idShape: "store",
|
|
624
|
+
},
|
|
625
|
+
persona: {
|
|
626
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
627
|
+
idShape: "kebab",
|
|
628
|
+
},
|
|
629
|
+
"kernel-fit-finding": {
|
|
630
|
+
status: [
|
|
631
|
+
"open",
|
|
632
|
+
"acknowledged",
|
|
633
|
+
"dismissed",
|
|
634
|
+
"promoted",
|
|
635
|
+
"superseded",
|
|
636
|
+
"archived",
|
|
637
|
+
],
|
|
638
|
+
enums: {
|
|
639
|
+
confidence: ["high", "medium", "low"],
|
|
640
|
+
"detected-via": ["detector", "flag"],
|
|
641
|
+
},
|
|
642
|
+
idShape: "kf",
|
|
643
|
+
ownerNullable: true,
|
|
644
|
+
},
|
|
645
|
+
"kernel-fit-flag": {
|
|
646
|
+
status: ["pending", "processed"],
|
|
647
|
+
enums: {
|
|
648
|
+
classification: [
|
|
649
|
+
"friction",
|
|
650
|
+
"missing-guardrail",
|
|
651
|
+
"kernel-vs-practice-mismatch",
|
|
652
|
+
"not-actionable",
|
|
653
|
+
"transcript-truncated",
|
|
654
|
+
],
|
|
655
|
+
},
|
|
656
|
+
idShape: "free",
|
|
657
|
+
floorExempt: ["owner"],
|
|
658
|
+
},
|
|
659
|
+
"coord-message": {
|
|
660
|
+
status: ["sent"],
|
|
661
|
+
idShape: "free",
|
|
662
|
+
},
|
|
663
|
+
infrastructure: {
|
|
664
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
665
|
+
idShape: "kebab",
|
|
666
|
+
},
|
|
667
|
+
"product-brief": {
|
|
668
|
+
status: ["draft", "current", "needs-refresh", "archived"],
|
|
669
|
+
enums: {
|
|
670
|
+
"technique-used": ["brainstorm", "forcing-questions", "project-brief"],
|
|
671
|
+
},
|
|
672
|
+
idShape: "kebab",
|
|
673
|
+
},
|
|
674
|
+
"data-architecture": {
|
|
675
|
+
status: ["draft", "current", "needs-refresh", "archived"],
|
|
676
|
+
idShape: "kebab",
|
|
677
|
+
},
|
|
678
|
+
"app-architecture": {
|
|
679
|
+
status: ["draft", "current", "needs-refresh", "archived"],
|
|
680
|
+
idShape: "kebab",
|
|
681
|
+
},
|
|
682
|
+
roadmap: {
|
|
683
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
684
|
+
enums: { source: ["local", "rhizome"] },
|
|
685
|
+
idShape: "kebab",
|
|
686
|
+
},
|
|
687
|
+
vision: {
|
|
688
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
689
|
+
idShape: "kebab",
|
|
690
|
+
},
|
|
691
|
+
glossary: {
|
|
692
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
693
|
+
idShape: "kebab",
|
|
694
|
+
},
|
|
695
|
+
"tech-stack": {
|
|
696
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
697
|
+
idShape: "kebab",
|
|
698
|
+
},
|
|
699
|
+
"ci-cd": {
|
|
700
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
701
|
+
idShape: "kebab",
|
|
702
|
+
},
|
|
703
|
+
"threat-model": {
|
|
704
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
705
|
+
idShape: "kebab",
|
|
706
|
+
},
|
|
707
|
+
"hardening-checklist": {
|
|
708
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
709
|
+
idShape: "kebab",
|
|
710
|
+
},
|
|
711
|
+
"incident-runbook": {
|
|
712
|
+
status: ["drafted", "current", "needs-refresh", "archived"],
|
|
713
|
+
idShape: "kebab",
|
|
714
|
+
},
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const ID_SHAPES = {
|
|
718
|
+
kebab: { re: KEBAB, label: "kebab-case" },
|
|
719
|
+
td: { re: /^TD-\d{4}-[a-z0-9]+(-[a-z0-9]+)*$/, label: "TD-NNNN-<kebab-slug>" },
|
|
720
|
+
adr: { re: /^ADR-\d{4}-[a-z0-9]+(-[a-z0-9]+)*$/, label: "ADR-NNNN-<kebab-slug>" },
|
|
721
|
+
kf: { re: /^KF-\d{4}-[a-z0-9]+(-[a-z0-9]+)*$/, label: "KF-NNNN-<kebab-slug>" },
|
|
722
|
+
store: {
|
|
723
|
+
re: /^(NOTION|LINEAR|GH|REPO):.+$/,
|
|
724
|
+
label: "<STORE>:<store-native-id> (NOTION | LINEAR | GH | REPO)",
|
|
725
|
+
},
|
|
726
|
+
free: { re: null, label: null },
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/** Statuses at or past which a change-spec's contract must be complete. */
|
|
730
|
+
const CHANGE_SPEC_BEYOND_DRAFT = (status) => status !== "draft" && status !== "archived";
|
|
731
|
+
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
// 4. Rule registry
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
//
|
|
736
|
+
// Each entry: { id, type, description, severity?, inferred?, check(a, world) }
|
|
737
|
+
// `check` returns an array of `{ message, line? }` (or a falsy value).
|
|
738
|
+
// `type` is the artifact type the rule applies to, `"*"` for every artifact.
|
|
739
|
+
|
|
740
|
+
const F = (message, line, severity) => ({ message, line, severity });
|
|
741
|
+
|
|
742
|
+
/** Non-null and non-empty-string. */
|
|
743
|
+
const present = (v) => v !== null && v !== undefined && String(v).trim() !== "";
|
|
744
|
+
|
|
745
|
+
const arr = (v) => (Array.isArray(v) ? v : v === null || v === undefined ? [] : [v]);
|
|
746
|
+
|
|
747
|
+
export const RULES = [
|
|
748
|
+
// -- Shared frontmatter floor ---------------------------------------------
|
|
749
|
+
{
|
|
750
|
+
id: "FM-01",
|
|
751
|
+
type: "*",
|
|
752
|
+
description:
|
|
753
|
+
"Shared frontmatter floor (KERNEL.md § Frontmatter contract): id / type / status / owner / created / updated present and non-null; id matches its type's shape and is kebab-case where the schema says so; dates are ISO 8601; controlled enums are case-sensitive; array fields are YAML arrays, never comma-separated strings.",
|
|
754
|
+
check(a) {
|
|
755
|
+
const out = [];
|
|
756
|
+
const schema = TYPE_SCHEMAS[a.type];
|
|
757
|
+
const exempt = new Set(schema?.floorExempt ?? []);
|
|
758
|
+
if (schema?.ownerNullable) exempt.add("owner");
|
|
759
|
+
for (const field of FLOOR_FIELDS) {
|
|
760
|
+
if (exempt.has(field)) continue;
|
|
761
|
+
if (!present(a.fm[field])) {
|
|
762
|
+
out.push(F(`missing or null required floor field \`${field}\``, a.lineOf(field)));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// id shape
|
|
766
|
+
const shape = ID_SHAPES[schema?.idShape ?? "kebab"];
|
|
767
|
+
if (shape?.re && present(a.fm.id) && !shape.re.test(String(a.fm.id))) {
|
|
768
|
+
out.push(
|
|
769
|
+
F(
|
|
770
|
+
`id \`${a.fm.id}\` does not match the ${a.type} id shape (${shape.label})`,
|
|
771
|
+
a.lineOf("id"),
|
|
772
|
+
),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
// status enum, case-sensitive
|
|
776
|
+
if (schema?.status && present(a.fm.status)) {
|
|
777
|
+
const s = String(a.fm.status);
|
|
778
|
+
if (!schema.status.includes(s)) {
|
|
779
|
+
const hint = schema.status.find((v) => v.toLowerCase() === s.toLowerCase());
|
|
780
|
+
out.push(
|
|
781
|
+
F(
|
|
782
|
+
`status \`${s}\` is not in the ${a.type} enum` +
|
|
783
|
+
(hint ? ` — enums are case-sensitive, did you mean \`${hint}\`?` : "") +
|
|
784
|
+
` (allowed: ${schema.status.join(" | ")})`,
|
|
785
|
+
a.lineOf("status"),
|
|
786
|
+
),
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// type-local enums
|
|
791
|
+
for (const [field, allowed] of Object.entries(schema?.enums ?? {})) {
|
|
792
|
+
const v = a.fm[field];
|
|
793
|
+
if (v === null || v === undefined) continue;
|
|
794
|
+
if (!allowed.includes(String(v))) {
|
|
795
|
+
const hint = allowed.find((x) => x.toLowerCase() === String(v).toLowerCase());
|
|
796
|
+
out.push(
|
|
797
|
+
F(
|
|
798
|
+
`${field} \`${v}\` is not in the controlled enum` +
|
|
799
|
+
(hint ? ` — enums are case-sensitive, did you mean \`${hint}\`?` : "") +
|
|
800
|
+
` (allowed: ${allowed.join(" | ")})`,
|
|
801
|
+
a.lineOf(field),
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// dates
|
|
807
|
+
for (const field of DATE_FIELDS) {
|
|
808
|
+
const v = a.fm[field];
|
|
809
|
+
if (v === null || v === undefined) continue;
|
|
810
|
+
if (!ISO_DATE.test(String(v)) || !isRealDate(String(v))) {
|
|
811
|
+
out.push(
|
|
812
|
+
F(`${field} \`${v}\` is not an ISO 8601 date (YYYY-MM-DD)`, a.lineOf(field)),
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// arrays are arrays
|
|
817
|
+
for (const field of ARRAY_FIELDS) {
|
|
818
|
+
if (!(field in a.fm)) continue;
|
|
819
|
+
const v = a.fm[field];
|
|
820
|
+
if (v === null || Array.isArray(v)) continue;
|
|
821
|
+
const raw = a.rawScalars[field];
|
|
822
|
+
const commaHint =
|
|
823
|
+
typeof raw === "string" && raw.includes(",")
|
|
824
|
+
? " — write a YAML array (`[a, b]` or a `- ` block), never a comma-separated string"
|
|
825
|
+
: " — expected a YAML array";
|
|
826
|
+
out.push(F(`${field} is a ${typeof v}, not an array${commaHint}`, a.lineOf(field)));
|
|
827
|
+
}
|
|
828
|
+
// surfaces enum
|
|
829
|
+
if (Array.isArray(a.fm.surfaces)) {
|
|
830
|
+
for (const s of a.fm.surfaces) {
|
|
831
|
+
if (!SURFACES.includes(String(s))) {
|
|
832
|
+
out.push(
|
|
833
|
+
F(
|
|
834
|
+
`surfaces entry \`${s}\` is not in the enum (${SURFACES.join(" | ")})`,
|
|
835
|
+
a.lineOf("surfaces"),
|
|
836
|
+
),
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return out;
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
// -- SP: change-spec -------------------------------------------------------
|
|
846
|
+
{
|
|
847
|
+
id: "SP-04",
|
|
848
|
+
type: "change-spec",
|
|
849
|
+
description:
|
|
850
|
+
"The Invariants section names at least three invariants. Below three, the change is either too narrow to need a spec or the author skipped the challenge.",
|
|
851
|
+
check(a) {
|
|
852
|
+
if (!CHANGE_SPEC_BEYOND_DRAFT(a.fm.status)) return [];
|
|
853
|
+
const sec = findSection(a.sections, "Invariants");
|
|
854
|
+
if (!sec) return [F("no `## Invariants` section")];
|
|
855
|
+
const n = bulletItems(sec).length;
|
|
856
|
+
if (n < 3) {
|
|
857
|
+
return [F(`Invariants has ${n} bullet(s); SP-04 requires at least 3`, sec.line)];
|
|
858
|
+
}
|
|
859
|
+
return [];
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
id: "SP-05",
|
|
864
|
+
type: "change-spec",
|
|
865
|
+
description:
|
|
866
|
+
"`in-scope` is a non-empty file allowlist once the spec is past draft. The implementer's write boundary and the CI scope gate both read this array.",
|
|
867
|
+
check(a) {
|
|
868
|
+
if (!CHANGE_SPEC_BEYOND_DRAFT(a.fm.status)) return [];
|
|
869
|
+
if (arr(a.fm["in-scope"]).length === 0) {
|
|
870
|
+
return [F("`in-scope` is empty; SP-05 requires a non-empty allowlist past `draft`", a.lineOf("in-scope"))];
|
|
871
|
+
}
|
|
872
|
+
return [];
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
id: "SP-06",
|
|
877
|
+
type: "change-spec",
|
|
878
|
+
description:
|
|
879
|
+
"The Scope Boundaries body section is filled in once the spec is past draft, and `out-of-scope` is declared (it may be an empty array, but the key must exist). Repo sources name SP-05/SP-06 jointly as \"Scope Boundaries is non-empty\"; the frontmatter half is SP-05 here and the body half is SP-06.",
|
|
880
|
+
inferred: true,
|
|
881
|
+
check(a) {
|
|
882
|
+
if (!CHANGE_SPEC_BEYOND_DRAFT(a.fm.status)) return [];
|
|
883
|
+
const out = [];
|
|
884
|
+
const sec = findSection(a.sections, "Scope Boundaries");
|
|
885
|
+
if (!sec) out.push(F("no `## Scope Boundaries` section"));
|
|
886
|
+
else if (substantiveLines(sec).length === 0) {
|
|
887
|
+
out.push(F("`## Scope Boundaries` carries no content", sec.line));
|
|
888
|
+
}
|
|
889
|
+
if (!("out-of-scope" in a.fm)) {
|
|
890
|
+
out.push(F("`out-of-scope` key is missing (it may be an empty array, but it is required)"));
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
id: "SP-09",
|
|
897
|
+
type: "change-spec",
|
|
898
|
+
description:
|
|
899
|
+
"A change-spec past draft has a driving user story, or declares exactly one of the three no-story carve-outs: Category A `internal-tooling: true`, Category B non-empty `enables`, Category C `area: bootstrap`.",
|
|
900
|
+
check(a) {
|
|
901
|
+
if (!CHANGE_SPEC_BEYOND_DRAFT(a.fm.status)) return [];
|
|
902
|
+
const stories = arr(a.fm["user-stories"]).filter(present);
|
|
903
|
+
if (stories.length > 0) return [];
|
|
904
|
+
if (a.fm["internal-tooling"] === true) return [];
|
|
905
|
+
if (arr(a.fm.enables).filter(present).length > 0) return [];
|
|
906
|
+
if (String(a.fm.area) === "bootstrap") return [];
|
|
907
|
+
return [
|
|
908
|
+
F(
|
|
909
|
+
"`user-stories` is empty and no carve-out is declared — set `internal-tooling: true` (A), a non-empty `enables` (B), or `area: bootstrap` (C)",
|
|
910
|
+
a.lineOf("user-stories"),
|
|
911
|
+
),
|
|
912
|
+
];
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
id: "SP-13",
|
|
917
|
+
type: "change-spec",
|
|
918
|
+
description:
|
|
919
|
+
"The three no-story categories are mutually exclusive: a change is Category A, B, or C — never two.",
|
|
920
|
+
check(a) {
|
|
921
|
+
const declared = [];
|
|
922
|
+
if (a.fm["internal-tooling"] === true) declared.push("internal-tooling: true (A)");
|
|
923
|
+
if (arr(a.fm.enables).filter(present).length > 0) declared.push("enables non-empty (B)");
|
|
924
|
+
if (String(a.fm.area) === "bootstrap") declared.push("area: bootstrap (C)");
|
|
925
|
+
if (declared.length > 1) {
|
|
926
|
+
return [
|
|
927
|
+
F(
|
|
928
|
+
`categories are mutually exclusive but this spec declares ${declared.length}: ${declared.join(", ")}`,
|
|
929
|
+
a.lineOf("internal-tooling"),
|
|
930
|
+
),
|
|
931
|
+
];
|
|
932
|
+
}
|
|
933
|
+
return [];
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
id: "SP-14",
|
|
938
|
+
type: "change-spec",
|
|
939
|
+
description:
|
|
940
|
+
"Reciprocity `change-spec.enables ↔ change-spec.enabled-by`: every id in `enables` names a change-spec that exists on disk and lists this change-id in its `enabled-by`. Both halves must be in the same state on disk — the pair lands in one commit.",
|
|
941
|
+
check(a, world) {
|
|
942
|
+
const out = [];
|
|
943
|
+
for (const target of arr(a.fm.enables).filter(present)) {
|
|
944
|
+
const other = world.byId.get(String(target));
|
|
945
|
+
if (!other || other.type !== "change-spec") {
|
|
946
|
+
out.push(
|
|
947
|
+
F(
|
|
948
|
+
`enables \`${target}\` names no change-spec on disk (forward references are legal only at authoring time)`,
|
|
949
|
+
a.lineOf("enables"),
|
|
950
|
+
),
|
|
951
|
+
);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (!arr(other.fm["enabled-by"]).map(String).includes(String(a.fm.id))) {
|
|
955
|
+
out.push(
|
|
956
|
+
F(
|
|
957
|
+
`enables \`${target}\` but ${other.relpath} does not list \`${a.fm.id}\` in \`enabled-by\` — the reciprocal half is missing`,
|
|
958
|
+
a.lineOf("enables"),
|
|
959
|
+
),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
for (const source of arr(a.fm["enabled-by"]).filter(present)) {
|
|
964
|
+
const other = world.byId.get(String(source));
|
|
965
|
+
if (!other || other.type !== "change-spec") {
|
|
966
|
+
out.push(
|
|
967
|
+
F(`enabled-by \`${source}\` names no change-spec on disk`, a.lineOf("enabled-by")),
|
|
968
|
+
);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (!arr(other.fm.enables).map(String).includes(String(a.fm.id))) {
|
|
972
|
+
out.push(
|
|
973
|
+
F(
|
|
974
|
+
`enabled-by \`${source}\` but ${other.relpath} does not list \`${a.fm.id}\` in \`enables\``,
|
|
975
|
+
a.lineOf("enabled-by"),
|
|
976
|
+
),
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return out;
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
|
|
984
|
+
// -- TD: tech-debt ---------------------------------------------------------
|
|
985
|
+
{
|
|
986
|
+
id: "TD-01",
|
|
987
|
+
type: "tech-debt",
|
|
988
|
+
description:
|
|
989
|
+
"Reciprocity `tech-debt.introduced-by ↔ change-spec.creates-tech-debt`: when a TD names an originating change-spec, that spec exists and lists this TD.",
|
|
990
|
+
check(a, world) {
|
|
991
|
+
const introducedBy = a.fm["introduced-by"];
|
|
992
|
+
if (!present(introducedBy)) return [];
|
|
993
|
+
const spec = world.byId.get(String(introducedBy));
|
|
994
|
+
if (!spec || spec.type !== "change-spec") {
|
|
995
|
+
return [
|
|
996
|
+
F(
|
|
997
|
+
`introduced-by \`${introducedBy}\` names no change-spec on disk`,
|
|
998
|
+
a.lineOf("introduced-by"),
|
|
999
|
+
),
|
|
1000
|
+
];
|
|
1001
|
+
}
|
|
1002
|
+
if (!arr(spec.fm["creates-tech-debt"]).map(String).includes(String(a.fm.id))) {
|
|
1003
|
+
return [
|
|
1004
|
+
F(
|
|
1005
|
+
`introduced-by \`${introducedBy}\` but ${spec.relpath} does not list \`${a.fm.id}\` in \`creates-tech-debt\` — the reciprocal half is missing`,
|
|
1006
|
+
a.lineOf("introduced-by"),
|
|
1007
|
+
),
|
|
1008
|
+
];
|
|
1009
|
+
}
|
|
1010
|
+
return [];
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
id: "TD-02",
|
|
1015
|
+
type: "tech-debt",
|
|
1016
|
+
description:
|
|
1017
|
+
"`severity: critical` requires a `target-resolve-by` date. Documented as surfacing-only in v1, so this reports as a warning.",
|
|
1018
|
+
severity: "warn",
|
|
1019
|
+
check(a) {
|
|
1020
|
+
if (String(a.fm.severity) !== "critical") return [];
|
|
1021
|
+
if (present(a.fm["target-resolve-by"])) return [];
|
|
1022
|
+
return [
|
|
1023
|
+
F(
|
|
1024
|
+
"severity is `critical` but `target-resolve-by` is null",
|
|
1025
|
+
a.lineOf("target-resolve-by") ?? a.lineOf("severity"),
|
|
1026
|
+
),
|
|
1027
|
+
];
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
id: "TD-04",
|
|
1032
|
+
type: "tech-debt",
|
|
1033
|
+
description:
|
|
1034
|
+
"Reciprocity `tech-debt.resolved-by ↔ change-spec.resolves-tech-debt`: a resolved TD's `resolved-by` names a change-spec that lists it back. Checked from both directions so a one-sided write is caught wherever the validator is pointed.",
|
|
1035
|
+
check(a, world) {
|
|
1036
|
+
const resolvedBy = a.fm["resolved-by"];
|
|
1037
|
+
if (!present(resolvedBy)) return [];
|
|
1038
|
+
const spec = world.byId.get(String(resolvedBy));
|
|
1039
|
+
if (!spec || spec.type !== "change-spec") {
|
|
1040
|
+
return [
|
|
1041
|
+
F(`resolved-by \`${resolvedBy}\` names no change-spec on disk`, a.lineOf("resolved-by")),
|
|
1042
|
+
];
|
|
1043
|
+
}
|
|
1044
|
+
if (!arr(spec.fm["resolves-tech-debt"]).map(String).includes(String(a.fm.id))) {
|
|
1045
|
+
return [
|
|
1046
|
+
F(
|
|
1047
|
+
`resolved-by \`${resolvedBy}\` but ${spec.relpath} does not list \`${a.fm.id}\` in \`resolves-tech-debt\` — the reciprocal half is missing`,
|
|
1048
|
+
a.lineOf("resolved-by"),
|
|
1049
|
+
),
|
|
1050
|
+
];
|
|
1051
|
+
}
|
|
1052
|
+
return [];
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
id: "TD-04",
|
|
1057
|
+
variant: "change-spec-half",
|
|
1058
|
+
type: "change-spec",
|
|
1059
|
+
description:
|
|
1060
|
+
"Reciprocity from the change-spec side: every id in `resolves-tech-debt` names a tech-debt artifact on disk, and once the change is `shipped` that TD carries `resolved-by: <this change>`.",
|
|
1061
|
+
check(a, world) {
|
|
1062
|
+
const out = [];
|
|
1063
|
+
for (const tdId of arr(a.fm["resolves-tech-debt"]).filter(present)) {
|
|
1064
|
+
const td = world.byId.get(String(tdId));
|
|
1065
|
+
if (!td || td.type !== "tech-debt") {
|
|
1066
|
+
out.push(
|
|
1067
|
+
F(
|
|
1068
|
+
`resolves-tech-debt \`${tdId}\` names no tech-debt artifact on disk`,
|
|
1069
|
+
a.lineOf("resolves-tech-debt"),
|
|
1070
|
+
),
|
|
1071
|
+
);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (String(a.fm.status) === "shipped") {
|
|
1075
|
+
if (String(td.fm["resolved-by"] ?? "") !== String(a.fm.id)) {
|
|
1076
|
+
out.push(
|
|
1077
|
+
F(
|
|
1078
|
+
`change-spec is \`shipped\` and resolves \`${tdId}\`, but ${td.relpath} has \`resolved-by: ${td.fm["resolved-by"]}\` — run /hstack:finalize`,
|
|
1079
|
+
a.lineOf("resolves-tech-debt"),
|
|
1080
|
+
),
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return out;
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
id: "TD-05",
|
|
1090
|
+
type: "tech-debt",
|
|
1091
|
+
description: "`status: resolved` requires `resolved-by` non-null.",
|
|
1092
|
+
check(a) {
|
|
1093
|
+
if (String(a.fm.status) !== "resolved") return [];
|
|
1094
|
+
if (present(a.fm["resolved-by"])) return [];
|
|
1095
|
+
return [F("status is `resolved` but `resolved-by` is null", a.lineOf("resolved-by") ?? a.lineOf("status"))];
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
id: "TD-06",
|
|
1100
|
+
type: "tech-debt",
|
|
1101
|
+
description:
|
|
1102
|
+
"`status: wontfix` requires both `wontfix-reason` and `wontfix-accepted-alternative` non-null.",
|
|
1103
|
+
check(a) {
|
|
1104
|
+
if (String(a.fm.status) !== "wontfix") return [];
|
|
1105
|
+
const out = [];
|
|
1106
|
+
for (const field of ["wontfix-reason", "wontfix-accepted-alternative"]) {
|
|
1107
|
+
if (!present(a.fm[field])) {
|
|
1108
|
+
out.push(F(`status is \`wontfix\` but \`${field}\` is null`, a.lineOf(field) ?? a.lineOf("status")));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return out;
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
id: "TD-07",
|
|
1116
|
+
type: "tech-debt",
|
|
1117
|
+
description:
|
|
1118
|
+
"`status: stale-no-longer-reproducible` requires both `stale-verified-at` and `stale-verification-method` non-null.",
|
|
1119
|
+
check(a) {
|
|
1120
|
+
if (String(a.fm.status) !== "stale-no-longer-reproducible") return [];
|
|
1121
|
+
const out = [];
|
|
1122
|
+
for (const field of ["stale-verified-at", "stale-verification-method"]) {
|
|
1123
|
+
if (!present(a.fm[field])) {
|
|
1124
|
+
out.push(F(`status is \`stale-no-longer-reproducible\` but \`${field}\` is null`, a.lineOf(field) ?? a.lineOf("status")));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return out;
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
|
|
1131
|
+
// -- AD: ADR ---------------------------------------------------------------
|
|
1132
|
+
{
|
|
1133
|
+
id: "AD-01",
|
|
1134
|
+
type: "adr",
|
|
1135
|
+
description:
|
|
1136
|
+
"ADR ids are sequential with no gaps. Reported once against the lowest-numbered ADR in the set so a gap does not fan out into a finding per file.",
|
|
1137
|
+
check(a, world) {
|
|
1138
|
+
const adrs = (world.byType.get("adr") ?? [])
|
|
1139
|
+
.map((x) => ({ x, n: adrNumber(x.fm.id) }))
|
|
1140
|
+
.filter((e) => e.n !== null)
|
|
1141
|
+
.sort((p, q) => p.n - q.n);
|
|
1142
|
+
if (adrs.length === 0 || adrs[0].x !== a) return []; // report once
|
|
1143
|
+
const out = [];
|
|
1144
|
+
const seen = new Map();
|
|
1145
|
+
for (const { x, n } of adrs) {
|
|
1146
|
+
if (seen.has(n)) {
|
|
1147
|
+
out.push(F(`ADR number ${pad4(n)} is used twice: ${seen.get(n)} and ${x.relpath}`));
|
|
1148
|
+
} else {
|
|
1149
|
+
seen.set(n, x.relpath);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const nums = [...seen.keys()].sort((p, q) => p - q);
|
|
1153
|
+
const gaps = [];
|
|
1154
|
+
for (let n = nums[0]; n < nums[nums.length - 1]; n++) {
|
|
1155
|
+
if (!seen.has(n)) gaps.push(pad4(n));
|
|
1156
|
+
}
|
|
1157
|
+
if (gaps.length > 0) {
|
|
1158
|
+
out.push(
|
|
1159
|
+
F(
|
|
1160
|
+
`ADR ids are not sequential — missing ${gaps.join(", ")} between ADR-${pad4(nums[0])} and ADR-${pad4(nums[nums.length - 1])}`,
|
|
1161
|
+
),
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
return out;
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
id: "AD-02",
|
|
1169
|
+
type: "adr",
|
|
1170
|
+
description:
|
|
1171
|
+
"Reciprocity `ADR.supersedes ↔ ADR.superseded-by`: the superseded ADR names this one back, and its status is `superseded`.",
|
|
1172
|
+
check(a, world) {
|
|
1173
|
+
const out = [];
|
|
1174
|
+
const supersedes = a.fm.supersedes;
|
|
1175
|
+
if (present(supersedes)) {
|
|
1176
|
+
const other = world.byId.get(String(supersedes));
|
|
1177
|
+
if (!other || other.type !== "adr") {
|
|
1178
|
+
out.push(F(`supersedes \`${supersedes}\` names no ADR on disk`, a.lineOf("supersedes")));
|
|
1179
|
+
} else {
|
|
1180
|
+
if (String(other.fm["superseded-by"] ?? "") !== String(a.fm.id)) {
|
|
1181
|
+
out.push(
|
|
1182
|
+
F(
|
|
1183
|
+
`supersedes \`${supersedes}\` but ${other.relpath} has \`superseded-by: ${other.fm["superseded-by"]}\` — the reciprocal half is missing`,
|
|
1184
|
+
a.lineOf("supersedes"),
|
|
1185
|
+
),
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
if (String(other.fm.status) !== "superseded") {
|
|
1189
|
+
out.push(
|
|
1190
|
+
F(
|
|
1191
|
+
`supersedes \`${supersedes}\` but ${other.relpath} is at \`status: ${other.fm.status}\`, not \`superseded\``,
|
|
1192
|
+
a.lineOf("supersedes"),
|
|
1193
|
+
),
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const supersededBy = a.fm["superseded-by"];
|
|
1199
|
+
if (present(supersededBy)) {
|
|
1200
|
+
const other = world.byId.get(String(supersededBy));
|
|
1201
|
+
if (!other || other.type !== "adr") {
|
|
1202
|
+
out.push(
|
|
1203
|
+
F(`superseded-by \`${supersededBy}\` names no ADR on disk`, a.lineOf("superseded-by")),
|
|
1204
|
+
);
|
|
1205
|
+
} else if (String(other.fm.supersedes ?? "") !== String(a.fm.id)) {
|
|
1206
|
+
out.push(
|
|
1207
|
+
F(
|
|
1208
|
+
`superseded-by \`${supersededBy}\` but ${other.relpath} does not carry \`supersedes: ${a.fm.id}\``,
|
|
1209
|
+
a.lineOf("superseded-by"),
|
|
1210
|
+
),
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return out;
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
id: "AD-03",
|
|
1219
|
+
type: "adr",
|
|
1220
|
+
description:
|
|
1221
|
+
"Fixed Nygard section structure: Title, Status, Context, Decision, Consequences, Alternatives Considered, Forecloses / Enables — all present and non-empty.",
|
|
1222
|
+
check(a) {
|
|
1223
|
+
const required = [
|
|
1224
|
+
"Title",
|
|
1225
|
+
"Status",
|
|
1226
|
+
"Context",
|
|
1227
|
+
"Decision",
|
|
1228
|
+
"Consequences",
|
|
1229
|
+
"Alternatives Considered",
|
|
1230
|
+
"Forecloses / Enables",
|
|
1231
|
+
];
|
|
1232
|
+
return requireSections(a, required);
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
id: "AD-04",
|
|
1237
|
+
type: "adr",
|
|
1238
|
+
description: "`status: superseded` requires `superseded-by` non-null.",
|
|
1239
|
+
check(a) {
|
|
1240
|
+
if (String(a.fm.status) !== "superseded") return [];
|
|
1241
|
+
if (present(a.fm["superseded-by"])) return [];
|
|
1242
|
+
return [
|
|
1243
|
+
F("status is `superseded` but `superseded-by` is null", a.lineOf("superseded-by") ?? a.lineOf("status")),
|
|
1244
|
+
];
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
{
|
|
1248
|
+
id: "KF-04",
|
|
1249
|
+
variant: "adr-half",
|
|
1250
|
+
type: "adr",
|
|
1251
|
+
description:
|
|
1252
|
+
"Reciprocity from the ADR side: every id in `promoted-from-kernel-fit` names a kernel-fit finding whose `promoted-to` points back at this ADR.",
|
|
1253
|
+
check(a, world) {
|
|
1254
|
+
const out = [];
|
|
1255
|
+
for (const kfId of arr(a.fm["promoted-from-kernel-fit"]).filter(present)) {
|
|
1256
|
+
const kf = world.byId.get(String(kfId));
|
|
1257
|
+
if (!kf || kf.type !== "kernel-fit-finding") {
|
|
1258
|
+
out.push(
|
|
1259
|
+
F(
|
|
1260
|
+
`promoted-from-kernel-fit \`${kfId}\` names no kernel-fit finding on disk`,
|
|
1261
|
+
a.lineOf("promoted-from-kernel-fit"),
|
|
1262
|
+
),
|
|
1263
|
+
);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
if (String(kf.fm["promoted-to"] ?? "") !== `adr:${a.fm.id}`) {
|
|
1267
|
+
out.push(
|
|
1268
|
+
F(
|
|
1269
|
+
`promoted-from-kernel-fit \`${kfId}\` but ${kf.relpath} has \`promoted-to: ${kf.fm["promoted-to"]}\` (expected \`adr:${a.fm.id}\`)`,
|
|
1270
|
+
a.lineOf("promoted-from-kernel-fit"),
|
|
1271
|
+
),
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return out;
|
|
1276
|
+
},
|
|
1277
|
+
},
|
|
1278
|
+
|
|
1279
|
+
// -- Per-change artifacts: parent linkage ---------------------------------
|
|
1280
|
+
{
|
|
1281
|
+
id: "TS-01",
|
|
1282
|
+
type: "test-plan",
|
|
1283
|
+
description:
|
|
1284
|
+
"`parent-change` names the change-spec that owns the enclosing change folder.",
|
|
1285
|
+
check: parentChangeMatches,
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
id: "PL-01",
|
|
1289
|
+
type: "plan",
|
|
1290
|
+
description:
|
|
1291
|
+
"`parent-change` names the change-spec that owns the enclosing change folder.",
|
|
1292
|
+
check: parentChangeMatches,
|
|
1293
|
+
},
|
|
1294
|
+
{
|
|
1295
|
+
id: "SR-01",
|
|
1296
|
+
type: "security-review",
|
|
1297
|
+
description:
|
|
1298
|
+
"`parent-change` names the change-spec that owns the enclosing change folder, and the scores map covers every hardening layer. No repo source states SR-01's text; implemented by analogy with TS-01 / PL-01 and the security-reviewer's Definition of Done, and flagged as inferred.",
|
|
1299
|
+
inferred: true,
|
|
1300
|
+
check(a, world) {
|
|
1301
|
+
const out = parentChangeMatches(a, world);
|
|
1302
|
+
const scores = a.fm.scores;
|
|
1303
|
+
if (scores === null || typeof scores !== "object" || Array.isArray(scores)) {
|
|
1304
|
+
out.push(F("`scores` is not a map of hardening layers", a.lineOf("scores")));
|
|
1305
|
+
return out;
|
|
1306
|
+
}
|
|
1307
|
+
const allowed = ["pass", "concerns", "fail", "not-applicable"];
|
|
1308
|
+
for (const layer of HARDENING_LAYERS) {
|
|
1309
|
+
if (!(layer in scores)) {
|
|
1310
|
+
out.push(F(`\`scores\` is missing hardening layer \`${layer}\``, a.lineOf("scores")));
|
|
1311
|
+
} else if (!allowed.includes(String(scores[layer]))) {
|
|
1312
|
+
out.push(
|
|
1313
|
+
F(
|
|
1314
|
+
`scores.${layer} \`${scores[layer]}\` is not in the enum (${allowed.join(" | ")})`,
|
|
1315
|
+
a.lineOf("scores"),
|
|
1316
|
+
),
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return out;
|
|
1321
|
+
},
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
id: "DR-01",
|
|
1325
|
+
type: "data-review",
|
|
1326
|
+
severity: "warn",
|
|
1327
|
+
description:
|
|
1328
|
+
"Every new table named in the Schema Changes section appears in `rls-coverage.new-tables`. The section is prose, so table extraction is a heuristic (an identifier immediately following a \"new table\" phrase) and this reports as a warning.",
|
|
1329
|
+
check(a) {
|
|
1330
|
+
const cov = a.fm["rls-coverage"]?.["new-tables"];
|
|
1331
|
+
const covered = cov && typeof cov === "object" ? Object.keys(cov) : [];
|
|
1332
|
+
const declared = newTablesFromSchemaChanges(a);
|
|
1333
|
+
const out = [];
|
|
1334
|
+
for (const t of declared) {
|
|
1335
|
+
if (!covered.includes(t)) {
|
|
1336
|
+
out.push(
|
|
1337
|
+
F(
|
|
1338
|
+
`new table \`${t}\` is named in Schema Changes but absent from \`rls-coverage.new-tables\``,
|
|
1339
|
+
a.lineOf("rls-coverage"),
|
|
1340
|
+
),
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return out;
|
|
1345
|
+
},
|
|
1346
|
+
},
|
|
1347
|
+
{
|
|
1348
|
+
id: "V-01",
|
|
1349
|
+
type: "verification",
|
|
1350
|
+
description:
|
|
1351
|
+
"`phase-coverage` keys equal the parent plan's `steps-completed`. A verification that covers a different phase set than the implementer completed is not evidence.",
|
|
1352
|
+
check(a, world) {
|
|
1353
|
+
const out = parentChangeMatches(a, world);
|
|
1354
|
+
const plan = siblingOfType(a, world, "plan");
|
|
1355
|
+
if (!plan) return out;
|
|
1356
|
+
const completed = arr(plan.fm["steps-completed"]).map(String).sort();
|
|
1357
|
+
const cov = a.fm["phase-coverage"];
|
|
1358
|
+
const covKeys =
|
|
1359
|
+
cov && typeof cov === "object" && !Array.isArray(cov) ? Object.keys(cov).sort() : [];
|
|
1360
|
+
if (completed.length === 0 && covKeys.length === 0) return out;
|
|
1361
|
+
if (JSON.stringify(completed) !== JSON.stringify(covKeys)) {
|
|
1362
|
+
out.push(
|
|
1363
|
+
F(
|
|
1364
|
+
`phase-coverage keys [${covKeys.join(", ")}] do not equal ${plan.relpath} steps-completed [${completed.join(", ")}]`,
|
|
1365
|
+
a.lineOf("phase-coverage"),
|
|
1366
|
+
),
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
return out;
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
id: "AR-01",
|
|
1374
|
+
type: "adversarial-review",
|
|
1375
|
+
description:
|
|
1376
|
+
"A review that reaches a findings status with an EMPTY `findings` array carries `findings-fewer-than-floor: true`, a non-null `justification-when-fewer`, and a filled Findings Floor Justification section. Reading a change cold and reporting nothing is a claim, and the artifact defends it. The count above zero is not gated (ADR-0014): `findings-floor` is the area's expectation, measured by the telemetry sidecar, and whether a finding is real is the reviewer's judgment, not the validator's arithmetic.",
|
|
1377
|
+
check(a, world) {
|
|
1378
|
+
const out = parentChangeMatches(a, world);
|
|
1379
|
+
const findings = arr(a.fm.findings);
|
|
1380
|
+
const empty = findings.length === 0 && isTerminal(a, ["findings-open", "findings-resolved"]);
|
|
1381
|
+
if (empty && a.fm["findings-fewer-than-floor"] !== true) {
|
|
1382
|
+
out.push(
|
|
1383
|
+
F(
|
|
1384
|
+
`status is \`${a.fm.status}\` with no findings; set \`findings-fewer-than-floor: true\` and defend the empty result, or file what you found`,
|
|
1385
|
+
a.lineOf("findings"),
|
|
1386
|
+
),
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
if (a.fm["findings-fewer-than-floor"] === true) {
|
|
1390
|
+
if (!present(a.fm["justification-when-fewer"])) {
|
|
1391
|
+
out.push(
|
|
1392
|
+
F(
|
|
1393
|
+
"`findings-fewer-than-floor: true` but `justification-when-fewer` is null",
|
|
1394
|
+
a.lineOf("justification-when-fewer"),
|
|
1395
|
+
),
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
const sec = findSection(a.sections, "Findings Floor Justification");
|
|
1399
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
1400
|
+
out.push(F("`findings-fewer-than-floor: true` but the Findings Floor Justification section is empty"));
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return out;
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
id: "AR-02",
|
|
1408
|
+
type: "adversarial-review",
|
|
1409
|
+
description:
|
|
1410
|
+
"Every finding record carries id (F-01..F-N, sequential), category (controlled enum), severity, status, and a resolution of the shape `commit:<hash>` | `tech-debt:<id>` | `justified-in-prose`. `findings-resolved` additionally requires every finding at `status: resolved` with a resolution.",
|
|
1411
|
+
check(a) {
|
|
1412
|
+
const out = [];
|
|
1413
|
+
const findings = arr(a.fm.findings).filter((f) => f && typeof f === "object");
|
|
1414
|
+
findings.forEach((f, idx) => {
|
|
1415
|
+
const want = `F-${String(idx + 1).padStart(2, "0")}`;
|
|
1416
|
+
if (String(f.id ?? "") !== want) {
|
|
1417
|
+
out.push(F(`finding ${idx + 1} has id \`${f.id}\`; ids are sequential F-01..F-N (expected \`${want}\`)`, a.lineOf("findings")));
|
|
1418
|
+
}
|
|
1419
|
+
if (!AR_CATEGORIES.includes(String(f.category))) {
|
|
1420
|
+
out.push(
|
|
1421
|
+
F(
|
|
1422
|
+
`finding ${f.id ?? idx + 1} category \`${f.category}\` is not in the enum (${AR_CATEGORIES.join(" | ")})`,
|
|
1423
|
+
a.lineOf("findings"),
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
if (!AR_SEVERITIES.includes(String(f.severity))) {
|
|
1428
|
+
out.push(
|
|
1429
|
+
F(
|
|
1430
|
+
`finding ${f.id ?? idx + 1} severity \`${f.severity}\` is not in the enum (${AR_SEVERITIES.join(" | ")})`,
|
|
1431
|
+
a.lineOf("findings"),
|
|
1432
|
+
),
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
if (!["open", "resolved"].includes(String(f.status))) {
|
|
1436
|
+
out.push(
|
|
1437
|
+
F(`finding ${f.id ?? idx + 1} status \`${f.status}\` is not \`open\` or \`resolved\``, a.lineOf("findings")),
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
if (present(f.resolution) && !isValidResolution(String(f.resolution))) {
|
|
1441
|
+
out.push(
|
|
1442
|
+
F(
|
|
1443
|
+
`finding ${f.id ?? idx + 1} resolution \`${f.resolution}\` is not \`commit:<hash>\`, \`tech-debt:<id>\`, or \`justified-in-prose\``,
|
|
1444
|
+
a.lineOf("findings"),
|
|
1445
|
+
),
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
if (String(a.fm.status) === "findings-resolved") {
|
|
1450
|
+
for (const f of findings) {
|
|
1451
|
+
if (String(f.status) !== "resolved" || !present(f.resolution)) {
|
|
1452
|
+
out.push(
|
|
1453
|
+
F(
|
|
1454
|
+
`status is \`findings-resolved\` but finding ${f.id} is \`${f.status}\` with resolution \`${f.resolution}\``,
|
|
1455
|
+
a.lineOf("status"),
|
|
1456
|
+
),
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return out;
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
id: "AR-05",
|
|
1466
|
+
type: "adversarial-review",
|
|
1467
|
+
description:
|
|
1468
|
+
"A finding resolved as `tech-debt:<id>` references a tech-debt artifact that exists and, at write time, is at `open` or `in-progress`. A missing artifact is an error; a TD that has since reached a terminal status is the expected end-state of a shipped change and reports as a warning.",
|
|
1469
|
+
check(a, world) {
|
|
1470
|
+
const out = [];
|
|
1471
|
+
for (const f of arr(a.fm.findings).filter((x) => x && typeof x === "object")) {
|
|
1472
|
+
const res = String(f.resolution ?? "");
|
|
1473
|
+
if (!res.startsWith("tech-debt:")) continue;
|
|
1474
|
+
const tdId = res.slice("tech-debt:".length).trim();
|
|
1475
|
+
const td = world.byId.get(tdId);
|
|
1476
|
+
if (!td || td.type !== "tech-debt") {
|
|
1477
|
+
out.push(F(`finding ${f.id} resolves to \`${res}\` but no such tech-debt artifact exists`, a.lineOf("findings")));
|
|
1478
|
+
} else if (!["open", "in-progress"].includes(String(td.fm.status))) {
|
|
1479
|
+
out.push(
|
|
1480
|
+
F(
|
|
1481
|
+
`finding ${f.id} resolves to \`${res}\` but ${td.relpath} is at \`status: ${td.fm.status}\` (AR-05 wants open or in-progress at write time)`,
|
|
1482
|
+
a.lineOf("findings"),
|
|
1483
|
+
"warn",
|
|
1484
|
+
),
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
return out;
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
id: "AR-06",
|
|
1493
|
+
type: "adversarial-review",
|
|
1494
|
+
description:
|
|
1495
|
+
"`findings-floor` is 3 by default and 5 when the parent change-spec's `area` is in {agent, auth, billing}. Since ADR-0014 the value gates nothing — it is the area's expected finding count, and this rule keeps the declared number honest so the telemetry sidecar's `findings_floor` / `findings_count` pair aggregates to something.",
|
|
1496
|
+
check(a, world) {
|
|
1497
|
+
const parent = parentSpec(a, world);
|
|
1498
|
+
if (!parent) return [];
|
|
1499
|
+
const want = ["agent", "auth", "billing"].includes(String(parent.fm.area)) ? 5 : 3;
|
|
1500
|
+
const got = Number(a.fm["findings-floor"]);
|
|
1501
|
+
if (!Number.isFinite(got)) {
|
|
1502
|
+
return [F("`findings-floor` is not a number", a.lineOf("findings-floor"))];
|
|
1503
|
+
}
|
|
1504
|
+
// A stricter floor than the area demands is a choice, not a violation.
|
|
1505
|
+
if (got < want) {
|
|
1506
|
+
return [
|
|
1507
|
+
F(
|
|
1508
|
+
`findings-floor is ${got} but the parent change-spec's area is \`${parent.fm.area}\`, which requires at least ${want}`,
|
|
1509
|
+
a.lineOf("findings-floor"),
|
|
1510
|
+
),
|
|
1511
|
+
];
|
|
1512
|
+
}
|
|
1513
|
+
return [];
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
id: "AR-07",
|
|
1518
|
+
type: "adversarial-review",
|
|
1519
|
+
description:
|
|
1520
|
+
"When the parent change-spec's `resolves-tech-debt` is non-empty, the Methodology section carries an explicit \"Acceptance Satisfied\" subsection. Presence is mechanical; whether the confirmation is honest is a judgment the reviewer owns.",
|
|
1521
|
+
check(a, world) {
|
|
1522
|
+
const parent = parentSpec(a, world);
|
|
1523
|
+
if (!parent) return [];
|
|
1524
|
+
if (arr(parent.fm["resolves-tech-debt"]).filter(present).length === 0) return [];
|
|
1525
|
+
const hit = a.sections.find((s) => /acceptance[- ]satisfied/i.test(s.title));
|
|
1526
|
+
if (!hit) {
|
|
1527
|
+
return [
|
|
1528
|
+
F(
|
|
1529
|
+
`parent change-spec resolves tech-debt but no "Acceptance Satisfied" subsection is present`,
|
|
1530
|
+
),
|
|
1531
|
+
];
|
|
1532
|
+
}
|
|
1533
|
+
if (substantiveLines(hit).length === 0) {
|
|
1534
|
+
return [F(`"Acceptance Satisfied" subsection is empty`, hit.line)];
|
|
1535
|
+
}
|
|
1536
|
+
return [];
|
|
1537
|
+
},
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
// -- TS: test-plan ---------------------------------------------------------
|
|
1541
|
+
{
|
|
1542
|
+
id: "TS-02",
|
|
1543
|
+
type: "test-plan",
|
|
1544
|
+
description: "`challenge-prompts-answered` equals 3, and each of the three prompts carries an answer.",
|
|
1545
|
+
check(a) {
|
|
1546
|
+
const out = [];
|
|
1547
|
+
if (Number(a.fm["challenge-prompts-answered"]) !== 3) {
|
|
1548
|
+
out.push(
|
|
1549
|
+
F(
|
|
1550
|
+
`challenge-prompts-answered is ${a.fm["challenge-prompts-answered"]}; must equal 3`,
|
|
1551
|
+
a.lineOf("challenge-prompts-answered"),
|
|
1552
|
+
),
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
if (isTerminal(a, ["passed", "concerns-acknowledged"])) {
|
|
1556
|
+
const prompts = a.sections.filter(
|
|
1557
|
+
(s) => s.level === 3 && /^\((a|b|c)\)/.test(s.title),
|
|
1558
|
+
);
|
|
1559
|
+
const answered = prompts.filter((s) => substantiveLines(s).length > 0);
|
|
1560
|
+
if (answered.length < 3) {
|
|
1561
|
+
out.push(
|
|
1562
|
+
F(
|
|
1563
|
+
`${answered.length} of 3 challenge prompts carry an answer under \`## Challenge Prompts\``,
|
|
1564
|
+
),
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return out;
|
|
1569
|
+
},
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
id: "TS-03",
|
|
1573
|
+
type: "test-plan",
|
|
1574
|
+
description:
|
|
1575
|
+
"`tenant-isolation-tests` is non-empty when the parent change-spec's `surfaces` includes db, api, or agent.",
|
|
1576
|
+
check(a, world) {
|
|
1577
|
+
const parent = parentSpec(a, world);
|
|
1578
|
+
if (!parent) return [];
|
|
1579
|
+
const surfaces = arr(parent.fm.surfaces).map(String);
|
|
1580
|
+
if (!surfaces.some((s) => ["db", "api", "agent"].includes(s))) return [];
|
|
1581
|
+
if (arr(a.fm["tenant-isolation-tests"]).filter(present).length > 0) return [];
|
|
1582
|
+
return [
|
|
1583
|
+
F(
|
|
1584
|
+
`parent surfaces are [${surfaces.join(", ")}] but \`tenant-isolation-tests\` is empty`,
|
|
1585
|
+
a.lineOf("tenant-isolation-tests"),
|
|
1586
|
+
),
|
|
1587
|
+
];
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
id: "TS-04",
|
|
1592
|
+
type: "test-plan",
|
|
1593
|
+
description:
|
|
1594
|
+
"Status gating on partial layers: a `partial` coverage layer blocks `passed`; `concerns-acknowledged` requires `concerns-acknowledged-by` non-null and a filled Open Concerns section.",
|
|
1595
|
+
check(a) {
|
|
1596
|
+
const out = [];
|
|
1597
|
+
const layers = a.fm["coverage-layers"] ?? {};
|
|
1598
|
+
const allowed = ["addressed", "partial", "not-applicable"];
|
|
1599
|
+
const partial = Object.entries(layers).filter(([, v]) => String(v) === "partial");
|
|
1600
|
+
for (const [k, v] of Object.entries(layers)) {
|
|
1601
|
+
if (!allowed.includes(String(v))) {
|
|
1602
|
+
out.push(
|
|
1603
|
+
F(`coverage-layers.${k} \`${v}\` is not in the enum (${allowed.join(" | ")})`, a.lineOf("coverage-layers")),
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (String(a.fm.status) === "passed" && partial.length > 0) {
|
|
1608
|
+
out.push(
|
|
1609
|
+
F(
|
|
1610
|
+
`status is \`passed\` but ${partial.map(([k]) => k).join(", ")} coverage is \`partial\` — the terminal state for a deferred layer is \`concerns-acknowledged\``,
|
|
1611
|
+
a.lineOf("status"),
|
|
1612
|
+
),
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
if (String(a.fm.status) === "concerns-acknowledged") {
|
|
1616
|
+
if (!present(a.fm["concerns-acknowledged-by"])) {
|
|
1617
|
+
out.push(
|
|
1618
|
+
F(
|
|
1619
|
+
"status is `concerns-acknowledged` but `concerns-acknowledged-by` is null",
|
|
1620
|
+
a.lineOf("concerns-acknowledged-by"),
|
|
1621
|
+
),
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
const sec = findSection(a.sections, "Open Concerns");
|
|
1625
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
1626
|
+
out.push(F("status is `concerns-acknowledged` but the Open Concerns section is empty"));
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return out;
|
|
1630
|
+
},
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
id: "TS-05",
|
|
1634
|
+
type: "test-plan",
|
|
1635
|
+
description: "`fixture-strategy-declared` is true before `status: passed`.",
|
|
1636
|
+
check(a) {
|
|
1637
|
+
if (String(a.fm.status) !== "passed") return [];
|
|
1638
|
+
if (a.fm["fixture-strategy-declared"] === true) return [];
|
|
1639
|
+
return [
|
|
1640
|
+
F(
|
|
1641
|
+
"status is `passed` but `fixture-strategy-declared` is not true",
|
|
1642
|
+
a.lineOf("fixture-strategy-declared"),
|
|
1643
|
+
),
|
|
1644
|
+
];
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
id: "TS-06",
|
|
1649
|
+
type: "test-plan",
|
|
1650
|
+
severity: "warn",
|
|
1651
|
+
description:
|
|
1652
|
+
"Every change-spec invariant has a mapped test in `invariants-mapped`. The change-spec template gives invariants no ids, so the mechanizable proxy is count parity against the parent's Invariants bullets — reported as a warning because the proxy can disagree with a correct plan.",
|
|
1653
|
+
check(a, world) {
|
|
1654
|
+
if (!isTerminal(a, ["passed", "concerns-acknowledged"])) return [];
|
|
1655
|
+
const parent = parentSpec(a, world);
|
|
1656
|
+
if (!parent) return [];
|
|
1657
|
+
const sec = findSection(parent.sections, "Invariants");
|
|
1658
|
+
const want = sec ? bulletItems(sec).length : 0;
|
|
1659
|
+
const got = arr(a.fm["invariants-mapped"]).filter(present).length;
|
|
1660
|
+
if (want === 0) return [];
|
|
1661
|
+
if (got < want) {
|
|
1662
|
+
return [
|
|
1663
|
+
F(
|
|
1664
|
+
`invariants-mapped has ${got} entr(ies) against ${want} invariant bullet(s) in ${parent.relpath}`,
|
|
1665
|
+
a.lineOf("invariants-mapped"),
|
|
1666
|
+
),
|
|
1667
|
+
];
|
|
1668
|
+
}
|
|
1669
|
+
return [];
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
|
|
1673
|
+
// -- SR: security-review ---------------------------------------------------
|
|
1674
|
+
{
|
|
1675
|
+
id: "SR-02",
|
|
1676
|
+
type: "security-review",
|
|
1677
|
+
description:
|
|
1678
|
+
"`challenge-prompts-answered` equals 3, and the Challenge Prompts section carries three answers.",
|
|
1679
|
+
check(a) {
|
|
1680
|
+
const out = [];
|
|
1681
|
+
if (Number(a.fm["challenge-prompts-answered"]) !== 3) {
|
|
1682
|
+
out.push(
|
|
1683
|
+
F(
|
|
1684
|
+
`challenge-prompts-answered is ${a.fm["challenge-prompts-answered"]}; must equal 3`,
|
|
1685
|
+
a.lineOf("challenge-prompts-answered"),
|
|
1686
|
+
),
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
if (isTerminal(a, ["passed", "concerns-acknowledged"])) {
|
|
1690
|
+
const sec = findSection(a.sections, "Challenge Prompts");
|
|
1691
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
1692
|
+
const subs = a.sections.filter(
|
|
1693
|
+
(s) => s.level === 3 && /^\((a|b|c)\)/.test(s.title) && substantiveLines(s).length > 0,
|
|
1694
|
+
);
|
|
1695
|
+
if (subs.length < 3) {
|
|
1696
|
+
out.push(F("the Challenge Prompts section carries fewer than three answers"));
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
return out;
|
|
1701
|
+
},
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
id: "SR-03",
|
|
1705
|
+
type: "security-review",
|
|
1706
|
+
description:
|
|
1707
|
+
"`threat-model-delta-required` is true when the parent change-spec's surfaces touch agent / auth / api / db, and the Threat-Model Delta section is then non-empty.",
|
|
1708
|
+
check(a, world) {
|
|
1709
|
+
const parent = parentSpec(a, world);
|
|
1710
|
+
const out = [];
|
|
1711
|
+
if (parent) {
|
|
1712
|
+
const surfaces = arr(parent.fm.surfaces).map(String);
|
|
1713
|
+
const triggers = surfaces.some((s) => ["agent", "auth", "api", "db"].includes(s));
|
|
1714
|
+
if (triggers && a.fm["threat-model-delta-required"] !== true) {
|
|
1715
|
+
out.push(
|
|
1716
|
+
F(
|
|
1717
|
+
`parent surfaces are [${surfaces.join(", ")}] but \`threat-model-delta-required\` is not true`,
|
|
1718
|
+
a.lineOf("threat-model-delta-required"),
|
|
1719
|
+
),
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
if (a.fm["threat-model-delta-required"] === true && isTerminal(a, ["passed", "concerns-acknowledged"])) {
|
|
1724
|
+
const sec = findSection(a.sections, "Threat-Model Delta");
|
|
1725
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
1726
|
+
out.push(F("`threat-model-delta-required: true` but the Threat-Model Delta section is empty"));
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return out;
|
|
1730
|
+
},
|
|
1731
|
+
},
|
|
1732
|
+
{
|
|
1733
|
+
id: "SR-04",
|
|
1734
|
+
type: "security-review",
|
|
1735
|
+
description:
|
|
1736
|
+
"`status: concerns-acknowledged` requires `concerns-acknowledged-by` non-null and an Open Concerns section that enumerates each open concern.",
|
|
1737
|
+
check(a) {
|
|
1738
|
+
if (String(a.fm.status) !== "concerns-acknowledged") return [];
|
|
1739
|
+
const out = [];
|
|
1740
|
+
if (!present(a.fm["concerns-acknowledged-by"])) {
|
|
1741
|
+
out.push(
|
|
1742
|
+
F(
|
|
1743
|
+
"status is `concerns-acknowledged` but `concerns-acknowledged-by` is null",
|
|
1744
|
+
a.lineOf("concerns-acknowledged-by"),
|
|
1745
|
+
),
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
const sec = findSection(a.sections, "Open Concerns");
|
|
1749
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
1750
|
+
out.push(F("status is `concerns-acknowledged` but the Open Concerns section is empty"));
|
|
1751
|
+
}
|
|
1752
|
+
return out;
|
|
1753
|
+
},
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
id: "SR-05",
|
|
1757
|
+
type: "security-review",
|
|
1758
|
+
description:
|
|
1759
|
+
"`status: passed` is impossible when any hardening score is `concerns` or `fail`.",
|
|
1760
|
+
check(a) {
|
|
1761
|
+
if (String(a.fm.status) !== "passed") return [];
|
|
1762
|
+
const scores = a.fm.scores;
|
|
1763
|
+
if (!scores || typeof scores !== "object") return [];
|
|
1764
|
+
const bad = Object.entries(scores).filter(([, v]) =>
|
|
1765
|
+
["concerns", "fail"].includes(String(v)),
|
|
1766
|
+
);
|
|
1767
|
+
if (bad.length === 0) return [];
|
|
1768
|
+
return [
|
|
1769
|
+
F(
|
|
1770
|
+
`status is \`passed\` but ${bad.map(([k, v]) => `${k}: ${v}`).join(", ")}`,
|
|
1771
|
+
a.lineOf("status"),
|
|
1772
|
+
),
|
|
1773
|
+
];
|
|
1774
|
+
},
|
|
1775
|
+
},
|
|
1776
|
+
|
|
1777
|
+
// -- DR: data-review -------------------------------------------------------
|
|
1778
|
+
{
|
|
1779
|
+
id: "DR-02",
|
|
1780
|
+
type: "data-review",
|
|
1781
|
+
description:
|
|
1782
|
+
"`status: passed` requires every value in `rls-coverage.new-tables` to be `covered`.",
|
|
1783
|
+
check(a) {
|
|
1784
|
+
const cov = a.fm["rls-coverage"]?.["new-tables"];
|
|
1785
|
+
if (!cov || typeof cov !== "object") return [];
|
|
1786
|
+
const out = [];
|
|
1787
|
+
const allowed = ["covered", "partial", "missing"];
|
|
1788
|
+
for (const [table, v] of Object.entries(cov)) {
|
|
1789
|
+
if (!allowed.includes(String(v))) {
|
|
1790
|
+
out.push(
|
|
1791
|
+
F(
|
|
1792
|
+
`rls-coverage.new-tables.${table} \`${v}\` is not in the enum (${allowed.join(" | ")})`,
|
|
1793
|
+
a.lineOf("rls-coverage"),
|
|
1794
|
+
),
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
if (String(a.fm.status) === "passed") {
|
|
1799
|
+
const bad = Object.entries(cov).filter(([, v]) => String(v) !== "covered");
|
|
1800
|
+
if (bad.length > 0) {
|
|
1801
|
+
out.push(
|
|
1802
|
+
F(
|
|
1803
|
+
`status is \`passed\` but RLS coverage is ${bad.map(([k, v]) => `${k}: ${v}`).join(", ")}`,
|
|
1804
|
+
a.lineOf("status"),
|
|
1805
|
+
),
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return out;
|
|
1810
|
+
},
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
id: "DR-03",
|
|
1814
|
+
type: "data-review",
|
|
1815
|
+
description:
|
|
1816
|
+
"When any pgvector RPC is modified, `pgvector-changes.tenant-id-arg-present` must be true. A dropped tenant argument is a kernel-level stop condition, not a style note.",
|
|
1817
|
+
check(a) {
|
|
1818
|
+
const pg = a.fm["pgvector-changes"];
|
|
1819
|
+
if (!pg || typeof pg !== "object") return [];
|
|
1820
|
+
if (arr(pg["rpcs-modified"]).filter(present).length === 0) return [];
|
|
1821
|
+
if (pg["tenant-id-arg-present"] === true) return [];
|
|
1822
|
+
return [
|
|
1823
|
+
F(
|
|
1824
|
+
`pgvector RPCs are modified (${arr(pg["rpcs-modified"]).join(", ")}) but \`tenant-id-arg-present\` is ${pg["tenant-id-arg-present"]}`,
|
|
1825
|
+
a.lineOf("pgvector-changes"),
|
|
1826
|
+
),
|
|
1827
|
+
];
|
|
1828
|
+
},
|
|
1829
|
+
},
|
|
1830
|
+
{
|
|
1831
|
+
id: "DR-04",
|
|
1832
|
+
type: "data-review",
|
|
1833
|
+
description:
|
|
1834
|
+
"`migration-safety: risky` requires the Migration Safety section to enumerate locking behavior and mitigation. Presence and non-emptiness are mechanical; the adequacy of the mitigation is the data-specialist's judgment.",
|
|
1835
|
+
check(a) {
|
|
1836
|
+
if (String(a.fm["migration-safety"]) !== "risky") return [];
|
|
1837
|
+
const sec = findSection(a.sections, "Migration Safety");
|
|
1838
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
1839
|
+
return [F("`migration-safety: risky` but the Migration Safety section is empty")];
|
|
1840
|
+
}
|
|
1841
|
+
return [];
|
|
1842
|
+
},
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
id: "DR-05",
|
|
1846
|
+
type: "data-review",
|
|
1847
|
+
description:
|
|
1848
|
+
"Every table in `rls-coverage.new-tables` has an entry in the RLS Coverage section.",
|
|
1849
|
+
check(a) {
|
|
1850
|
+
const cov = a.fm["rls-coverage"]?.["new-tables"];
|
|
1851
|
+
if (!cov || typeof cov !== "object" || Object.keys(cov).length === 0) return [];
|
|
1852
|
+
const sec = findSection(a.sections, "RLS Coverage");
|
|
1853
|
+
if (!sec) return [F("`rls-coverage.new-tables` is populated but there is no RLS Coverage section")];
|
|
1854
|
+
const text = sectionText(sec);
|
|
1855
|
+
const out = [];
|
|
1856
|
+
for (const table of Object.keys(cov)) {
|
|
1857
|
+
if (!text.includes(table)) {
|
|
1858
|
+
out.push(F(`table \`${table}\` has no entry in the RLS Coverage section`, sec.line));
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
return out;
|
|
1862
|
+
},
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
id: "DR-06",
|
|
1866
|
+
type: "data-review",
|
|
1867
|
+
description: "`data-lifecycle` is one of the controlled values.",
|
|
1868
|
+
check(a) {
|
|
1869
|
+
const v = a.fm["data-lifecycle"];
|
|
1870
|
+
if (v === null || v === undefined) return [];
|
|
1871
|
+
const s = String(v);
|
|
1872
|
+
if (s === "retained-indefinitely" || s === "ephemeral" || /^retained-\d+-days$/.test(s)) {
|
|
1873
|
+
return [];
|
|
1874
|
+
}
|
|
1875
|
+
return [
|
|
1876
|
+
F(
|
|
1877
|
+
`data-lifecycle \`${s}\` is not in the enum (retained-indefinitely | retained-N-days | ephemeral)`,
|
|
1878
|
+
a.lineOf("data-lifecycle"),
|
|
1879
|
+
),
|
|
1880
|
+
];
|
|
1881
|
+
},
|
|
1882
|
+
},
|
|
1883
|
+
|
|
1884
|
+
// -- PL: plan --------------------------------------------------------------
|
|
1885
|
+
{
|
|
1886
|
+
id: "PL-02",
|
|
1887
|
+
type: "plan",
|
|
1888
|
+
description:
|
|
1889
|
+
"A plan with more than 12 phases requires `oversized-plan-justification`; the Phase Overview table and the Per-Phase Detail subsections must name the same phase set.",
|
|
1890
|
+
check(a) {
|
|
1891
|
+
const out = [];
|
|
1892
|
+
const phases = planPhases(a);
|
|
1893
|
+
if (phases.detail.length > 12 && !present(a.fm["oversized-plan-justification"])) {
|
|
1894
|
+
out.push(
|
|
1895
|
+
F(
|
|
1896
|
+
`${phases.detail.length} phases exceed the 12-phase norm but \`oversized-plan-justification\` is null`,
|
|
1897
|
+
a.lineOf("oversized-plan-justification"),
|
|
1898
|
+
),
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
if (phases.overview.length > 0 && phases.detail.length > 0) {
|
|
1902
|
+
const missing = phases.overview.filter((p) => !phases.detail.includes(p));
|
|
1903
|
+
const extra = phases.detail.filter((p) => !phases.overview.includes(p));
|
|
1904
|
+
for (const p of missing) {
|
|
1905
|
+
out.push(F(`phase \`${p}\` is in the Phase Overview table but has no Per-Phase Detail subsection`));
|
|
1906
|
+
}
|
|
1907
|
+
for (const p of extra) {
|
|
1908
|
+
out.push(F(`phase \`${p}\` has a Per-Phase Detail subsection but is not in the Phase Overview table`));
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return out;
|
|
1912
|
+
},
|
|
1913
|
+
},
|
|
1914
|
+
{
|
|
1915
|
+
id: "PL-03",
|
|
1916
|
+
type: "plan",
|
|
1917
|
+
description: "Every `steps-completed` entry matches a phase id declared in the plan body.",
|
|
1918
|
+
check(a) {
|
|
1919
|
+
const phases = planPhases(a);
|
|
1920
|
+
const known = new Set([...phases.overview, ...phases.detail]);
|
|
1921
|
+
if (known.size === 0) return [];
|
|
1922
|
+
const out = [];
|
|
1923
|
+
for (const step of arr(a.fm["steps-completed"]).filter(present)) {
|
|
1924
|
+
if (!known.has(String(step))) {
|
|
1925
|
+
out.push(
|
|
1926
|
+
F(
|
|
1927
|
+
`steps-completed entry \`${step}\` matches no phase id in the plan body (known: ${[...known].join(", ")})`,
|
|
1928
|
+
a.lineOf("steps-completed"),
|
|
1929
|
+
),
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return out;
|
|
1934
|
+
},
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
id: "PL-04",
|
|
1938
|
+
type: "plan",
|
|
1939
|
+
description:
|
|
1940
|
+
"Every Files Touched path is inside the parent change-spec's `in-scope` allowlist.",
|
|
1941
|
+
check(a, world) {
|
|
1942
|
+
const parent = parentSpec(a, world);
|
|
1943
|
+
if (!parent) return [];
|
|
1944
|
+
const inScope = arr(parent.fm["in-scope"]).filter(present).map(String);
|
|
1945
|
+
if (inScope.length === 0) return [];
|
|
1946
|
+
const out = [];
|
|
1947
|
+
for (const { phase, path, line } of filesTouched(a)) {
|
|
1948
|
+
if (!inScope.some((g) => globMatches(g, path))) {
|
|
1949
|
+
out.push(
|
|
1950
|
+
F(
|
|
1951
|
+
`phase \`${phase}\` touches \`${path}\`, which no \`in-scope\` glob in ${parent.relpath} covers`,
|
|
1952
|
+
line,
|
|
1953
|
+
),
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
return out;
|
|
1958
|
+
},
|
|
1959
|
+
},
|
|
1960
|
+
{
|
|
1961
|
+
id: "PL-05",
|
|
1962
|
+
type: "plan",
|
|
1963
|
+
description:
|
|
1964
|
+
"Plan status gating: `ready` requires all five sections filled; `completed` requires `steps-completed` to cover every phase in the body and `blocked-on` to be null.",
|
|
1965
|
+
check(a) {
|
|
1966
|
+
const status = String(a.fm.status);
|
|
1967
|
+
const out = [];
|
|
1968
|
+
if (["ready", "in-progress", "completed"].includes(status)) {
|
|
1969
|
+
out.push(
|
|
1970
|
+
...requireSections(a, [
|
|
1971
|
+
"Roadmap Alignment",
|
|
1972
|
+
"Phase Overview",
|
|
1973
|
+
"Per-Phase Detail",
|
|
1974
|
+
"Cross-Phase Risks",
|
|
1975
|
+
"Rollback",
|
|
1976
|
+
]),
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
if (status === "completed") {
|
|
1980
|
+
const phases = planPhases(a);
|
|
1981
|
+
const known = phases.detail.length > 0 ? phases.detail : phases.overview;
|
|
1982
|
+
const done = new Set(arr(a.fm["steps-completed"]).map(String));
|
|
1983
|
+
const missing = known.filter((p) => !done.has(p));
|
|
1984
|
+
if (missing.length > 0) {
|
|
1985
|
+
out.push(
|
|
1986
|
+
F(
|
|
1987
|
+
`status is \`completed\` but steps-completed omits ${missing.join(", ")}`,
|
|
1988
|
+
a.lineOf("steps-completed"),
|
|
1989
|
+
),
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
if (present(a.fm["blocked-on"])) {
|
|
1993
|
+
out.push(
|
|
1994
|
+
F(`status is \`completed\` but \`blocked-on\` is \`${a.fm["blocked-on"]}\``, a.lineOf("blocked-on")),
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return out;
|
|
1999
|
+
},
|
|
2000
|
+
},
|
|
2001
|
+
|
|
2002
|
+
// -- V: verification -------------------------------------------------------
|
|
2003
|
+
{
|
|
2004
|
+
id: "V-02",
|
|
2005
|
+
type: "verification",
|
|
2006
|
+
description: "Any `fail` in `test-results` blocks `status: passed`.",
|
|
2007
|
+
check(a) {
|
|
2008
|
+
const results = a.fm["test-results"];
|
|
2009
|
+
if (!results || typeof results !== "object") return [];
|
|
2010
|
+
const out = [];
|
|
2011
|
+
const allowed = ["pass", "fail", "pending", "not-run"];
|
|
2012
|
+
for (const [k, v] of Object.entries(results)) {
|
|
2013
|
+
if (!allowed.includes(String(v))) {
|
|
2014
|
+
out.push(
|
|
2015
|
+
F(`test-results.${k} \`${v}\` is not in the enum (${allowed.join(" | ")})`, a.lineOf("test-results")),
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (String(a.fm.status) === "passed") {
|
|
2020
|
+
const failed = Object.entries(results).filter(([, v]) => String(v) === "fail");
|
|
2021
|
+
if (failed.length > 0) {
|
|
2022
|
+
out.push(
|
|
2023
|
+
F(
|
|
2024
|
+
`status is \`passed\` but ${failed.map(([k]) => k).join(", ")} failed`,
|
|
2025
|
+
a.lineOf("status"),
|
|
2026
|
+
),
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
return out;
|
|
2031
|
+
},
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
id: "V-03",
|
|
2035
|
+
type: "verification",
|
|
2036
|
+
description:
|
|
2037
|
+
"`status: passed` requires `test-plan-coverage.tenant-isolation` to be `all-observed` or `not-applicable`; a `partial` or `missing` tenant-isolation observation is escalated, never passed.",
|
|
2038
|
+
check(a) {
|
|
2039
|
+
const cov = a.fm["test-plan-coverage"];
|
|
2040
|
+
if (!cov || typeof cov !== "object") return [];
|
|
2041
|
+
const out = [];
|
|
2042
|
+
const allowed = ["all-observed", "partial", "missing", "not-applicable", "pending"];
|
|
2043
|
+
const v = String(cov["tenant-isolation"]);
|
|
2044
|
+
if (cov["tenant-isolation"] !== undefined && !allowed.includes(v)) {
|
|
2045
|
+
out.push(
|
|
2046
|
+
F(
|
|
2047
|
+
`test-plan-coverage.tenant-isolation \`${v}\` is not in the enum (${allowed.join(" | ")})`,
|
|
2048
|
+
a.lineOf("test-plan-coverage"),
|
|
2049
|
+
),
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
if (String(a.fm.status) === "passed" && ["partial", "missing", "pending"].includes(v)) {
|
|
2053
|
+
out.push(
|
|
2054
|
+
F(
|
|
2055
|
+
`status is \`passed\` but test-plan-coverage.tenant-isolation is \`${v}\``,
|
|
2056
|
+
a.lineOf("status"),
|
|
2057
|
+
),
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
return out;
|
|
2061
|
+
},
|
|
2062
|
+
},
|
|
2063
|
+
{
|
|
2064
|
+
id: "V-04",
|
|
2065
|
+
type: "verification",
|
|
2066
|
+
description:
|
|
2067
|
+
"`status: passed` requires `test-plan-coverage.performance-budgets` to be `all-within-budget` or `not-applicable`.",
|
|
2068
|
+
check(a) {
|
|
2069
|
+
const cov = a.fm["test-plan-coverage"];
|
|
2070
|
+
if (!cov || typeof cov !== "object") return [];
|
|
2071
|
+
const out = [];
|
|
2072
|
+
const allowed = [
|
|
2073
|
+
"all-within-budget",
|
|
2074
|
+
"regressed",
|
|
2075
|
+
"missing",
|
|
2076
|
+
"not-applicable",
|
|
2077
|
+
"pending",
|
|
2078
|
+
];
|
|
2079
|
+
const v = String(cov["performance-budgets"]);
|
|
2080
|
+
if (cov["performance-budgets"] !== undefined && !allowed.includes(v)) {
|
|
2081
|
+
out.push(
|
|
2082
|
+
F(
|
|
2083
|
+
`test-plan-coverage.performance-budgets \`${v}\` is not in the enum (${allowed.join(" | ")})`,
|
|
2084
|
+
a.lineOf("test-plan-coverage"),
|
|
2085
|
+
),
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
if (String(a.fm.status) === "passed" && ["regressed", "missing", "pending"].includes(v)) {
|
|
2089
|
+
out.push(
|
|
2090
|
+
F(
|
|
2091
|
+
`status is \`passed\` but test-plan-coverage.performance-budgets is \`${v}\``,
|
|
2092
|
+
a.lineOf("status"),
|
|
2093
|
+
),
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
return out;
|
|
2097
|
+
},
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
id: "V-05",
|
|
2101
|
+
type: "verification",
|
|
2102
|
+
description:
|
|
2103
|
+
"A suite recorded as `not-run` is not evidence: `status: passed` requires unit / integration / e2e to be `pass` or `not-run` *with* a Discrepancies entry. The observed-test-count confirmation itself is the verifier's judgment and stays out of the validator.",
|
|
2104
|
+
check(a) {
|
|
2105
|
+
if (String(a.fm.status) !== "passed") return [];
|
|
2106
|
+
const results = a.fm["test-results"] ?? {};
|
|
2107
|
+
const notRun = ["unit", "integration", "e2e"].filter(
|
|
2108
|
+
(k) => String(results[k]) === "not-run",
|
|
2109
|
+
);
|
|
2110
|
+
const pending = Object.entries(results).filter(([, v]) => String(v) === "pending");
|
|
2111
|
+
const out = [];
|
|
2112
|
+
if (pending.length > 0) {
|
|
2113
|
+
out.push(
|
|
2114
|
+
F(
|
|
2115
|
+
`status is \`passed\` but ${pending.map(([k]) => k).join(", ")} is still \`pending\``,
|
|
2116
|
+
a.lineOf("test-results"),
|
|
2117
|
+
),
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
if (notRun.length > 0) {
|
|
2121
|
+
const sec = findSection(a.sections, "Discrepancies");
|
|
2122
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
2123
|
+
out.push(
|
|
2124
|
+
F(
|
|
2125
|
+
`${notRun.join(", ")} recorded as \`not-run\` at \`status: passed\` with no Discrepancies entry`,
|
|
2126
|
+
a.lineOf("test-results"),
|
|
2127
|
+
),
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
return out;
|
|
2132
|
+
},
|
|
2133
|
+
},
|
|
2134
|
+
|
|
2135
|
+
// -- KF: kernel-fit findings ----------------------------------------------
|
|
2136
|
+
{
|
|
2137
|
+
id: "KF-01",
|
|
2138
|
+
type: "kernel-fit-finding",
|
|
2139
|
+
description:
|
|
2140
|
+
"`evidence-row-count` equals `len(evidence-rows)` and is at least 1. No finding without cited evidence.",
|
|
2141
|
+
check(a) {
|
|
2142
|
+
const rows = arr(a.fm["evidence-rows"]);
|
|
2143
|
+
const count = Number(a.fm["evidence-row-count"]);
|
|
2144
|
+
const out = [];
|
|
2145
|
+
if (rows.length !== count) {
|
|
2146
|
+
out.push(
|
|
2147
|
+
F(
|
|
2148
|
+
`evidence-row-count is ${a.fm["evidence-row-count"]} but evidence-rows has ${rows.length} entr(ies)`,
|
|
2149
|
+
a.lineOf("evidence-row-count"),
|
|
2150
|
+
),
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
if (rows.length < 1) {
|
|
2154
|
+
out.push(F("evidence-rows is empty; a finding needs at least one cited row", a.lineOf("evidence-rows")));
|
|
2155
|
+
}
|
|
2156
|
+
return out;
|
|
2157
|
+
},
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
id: "KF-02",
|
|
2161
|
+
type: "kernel-fit-finding",
|
|
2162
|
+
description:
|
|
2163
|
+
"`confidence: high` requires `evidence-row-count >= 3` and at least two distinct change-specs cited in `evidence-rows`.",
|
|
2164
|
+
check(a) {
|
|
2165
|
+
if (String(a.fm.confidence) !== "high") return [];
|
|
2166
|
+
const rows = arr(a.fm["evidence-rows"]).filter((r) => r && typeof r === "object");
|
|
2167
|
+
const out = [];
|
|
2168
|
+
if (Number(a.fm["evidence-row-count"]) < 3) {
|
|
2169
|
+
out.push(
|
|
2170
|
+
F(
|
|
2171
|
+
`confidence is \`high\` but evidence-row-count is ${a.fm["evidence-row-count"]} (needs >= 3)`,
|
|
2172
|
+
a.lineOf("confidence"),
|
|
2173
|
+
),
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const changes = new Set(rows.map((r) => r.change).filter(present).map(String));
|
|
2177
|
+
if (changes.size < 2) {
|
|
2178
|
+
out.push(
|
|
2179
|
+
F(
|
|
2180
|
+
`confidence is \`high\` but evidence-rows cite ${changes.size} distinct change-spec(s) (needs >= 2)`,
|
|
2181
|
+
a.lineOf("confidence"),
|
|
2182
|
+
),
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
return out;
|
|
2186
|
+
},
|
|
2187
|
+
},
|
|
2188
|
+
{
|
|
2189
|
+
id: "KF-03",
|
|
2190
|
+
type: "kernel-fit-finding",
|
|
2191
|
+
description:
|
|
2192
|
+
"The mandatory Counter-explanations section carries two entries; fewer means `confidence` must be `low`.",
|
|
2193
|
+
check(a) {
|
|
2194
|
+
const sec = a.sections.find((s) => /^counter-explanations/i.test(s.title));
|
|
2195
|
+
if (!sec) return [F("no Counter-explanations section (challenge prompt is mandatory)")];
|
|
2196
|
+
const items = substantiveLines(sec).filter((l) => /^\s*(\d+\.|[-*])\s+\S/.test(l));
|
|
2197
|
+
if (items.length >= 2) return [];
|
|
2198
|
+
if (String(a.fm.confidence) === "low") return [];
|
|
2199
|
+
return [
|
|
2200
|
+
F(
|
|
2201
|
+
`Counter-explanations has ${items.length} entr(y|ies); fewer than two auto-downgrades confidence to \`low\` (currently \`${a.fm.confidence}\`)`,
|
|
2202
|
+
sec.line,
|
|
2203
|
+
),
|
|
2204
|
+
];
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
{
|
|
2208
|
+
id: "KF-04",
|
|
2209
|
+
type: "kernel-fit-finding",
|
|
2210
|
+
description:
|
|
2211
|
+
"`status: promoted` requires `promoted-to` non-null pointing at an ADR or tech-debt that exists and carries the reciprocal back-reference; at terminal-write (any other status) `promoted-to` stays null.",
|
|
2212
|
+
check(a, world) {
|
|
2213
|
+
const status = String(a.fm.status);
|
|
2214
|
+
const target = a.fm["promoted-to"];
|
|
2215
|
+
if (status !== "promoted") {
|
|
2216
|
+
if (present(target)) {
|
|
2217
|
+
return [
|
|
2218
|
+
F(
|
|
2219
|
+
`promoted-to is \`${target}\` but status is \`${status}\` — promotion is downstream of the analyst's terminal write`,
|
|
2220
|
+
a.lineOf("promoted-to"),
|
|
2221
|
+
),
|
|
2222
|
+
];
|
|
2223
|
+
}
|
|
2224
|
+
return [];
|
|
2225
|
+
}
|
|
2226
|
+
if (!present(target)) {
|
|
2227
|
+
return [F("status is `promoted` but `promoted-to` is null", a.lineOf("promoted-to"))];
|
|
2228
|
+
}
|
|
2229
|
+
const s = String(target);
|
|
2230
|
+
const m = /^(adr|tech-debt):(.+)$/.exec(s);
|
|
2231
|
+
if (!m) {
|
|
2232
|
+
return [
|
|
2233
|
+
F(
|
|
2234
|
+
`promoted-to \`${s}\` is not \`adr:<ADR-NNNN-slug>\` or \`tech-debt:<TD-NNNN-slug>\``,
|
|
2235
|
+
a.lineOf("promoted-to"),
|
|
2236
|
+
),
|
|
2237
|
+
];
|
|
2238
|
+
}
|
|
2239
|
+
const other = world.byId.get(m[2]);
|
|
2240
|
+
if (!other) {
|
|
2241
|
+
return [F(`promoted-to \`${s}\` names no artifact on disk`, a.lineOf("promoted-to"))];
|
|
2242
|
+
}
|
|
2243
|
+
if (m[1] === "adr") {
|
|
2244
|
+
if (!arr(other.fm["promoted-from-kernel-fit"]).map(String).includes(String(a.fm.id))) {
|
|
2245
|
+
return [
|
|
2246
|
+
F(
|
|
2247
|
+
`promoted-to \`${s}\` but ${other.relpath} does not list \`${a.fm.id}\` in \`promoted-from-kernel-fit\` — the reciprocal half is missing`,
|
|
2248
|
+
a.lineOf("promoted-to"),
|
|
2249
|
+
),
|
|
2250
|
+
];
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
return [];
|
|
2254
|
+
},
|
|
2255
|
+
},
|
|
2256
|
+
{
|
|
2257
|
+
id: "KF-05",
|
|
2258
|
+
type: "kernel-fit-finding",
|
|
2259
|
+
description:
|
|
2260
|
+
"`status: dismissed` requires a non-null `dismissed-reason`; at any other status `dismissed-reason` stays null. Presence is mechanical. Whether the reason says something a reader could re-evaluate in six months is `/hstack:kernel-fit-triage`'s judgment — the old `>= 50 characters` clause measured length where it meant substance (ADR-0014).",
|
|
2261
|
+
check(a) {
|
|
2262
|
+
const status = String(a.fm.status);
|
|
2263
|
+
const reason = a.fm["dismissed-reason"];
|
|
2264
|
+
if (status !== "dismissed") {
|
|
2265
|
+
if (present(reason)) {
|
|
2266
|
+
return [
|
|
2267
|
+
F(
|
|
2268
|
+
`dismissed-reason is set but status is \`${status}\` — dismissal is downstream of the analyst's terminal write`,
|
|
2269
|
+
a.lineOf("dismissed-reason"),
|
|
2270
|
+
),
|
|
2271
|
+
];
|
|
2272
|
+
}
|
|
2273
|
+
return [];
|
|
2274
|
+
}
|
|
2275
|
+
if (!present(reason)) {
|
|
2276
|
+
return [F("status is `dismissed` but `dismissed-reason` is null", a.lineOf("dismissed-reason"))];
|
|
2277
|
+
}
|
|
2278
|
+
// ADR-0014: the old `>= 50 chars` clause measured length where it meant
|
|
2279
|
+
// substance. It passed padding and rejected tight correct sentences.
|
|
2280
|
+
// Whether the reason holds up is the triage Skill's judgment.
|
|
2281
|
+
return [];
|
|
2282
|
+
},
|
|
2283
|
+
},
|
|
2284
|
+
|
|
2285
|
+
// -- MS: module-spec -------------------------------------------------------
|
|
2286
|
+
{
|
|
2287
|
+
id: "MS-01",
|
|
2288
|
+
type: "module-spec",
|
|
2289
|
+
description:
|
|
2290
|
+
"`paths` is non-empty and every glob resolves to something on disk. An unresolvable path means the module-to-paths mapping has drifted from the tree.",
|
|
2291
|
+
check(a, world) {
|
|
2292
|
+
const paths = arr(a.fm.paths).filter(present).map(String);
|
|
2293
|
+
if (paths.length === 0) {
|
|
2294
|
+
return [F("`paths` is empty; the module-to-paths mapping is required", a.lineOf("paths"))];
|
|
2295
|
+
}
|
|
2296
|
+
const out = [];
|
|
2297
|
+
for (const p of paths) {
|
|
2298
|
+
if (!pathGlobResolves(world.repoRoot, p)) {
|
|
2299
|
+
out.push(F(`\`paths\` entry \`${p}\` resolves to nothing on disk`, a.lineOf("paths")));
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
return out;
|
|
2303
|
+
},
|
|
2304
|
+
},
|
|
2305
|
+
{
|
|
2306
|
+
id: "MS-02",
|
|
2307
|
+
type: "module-spec",
|
|
2308
|
+
description:
|
|
2309
|
+
"No module-spec's `paths` overlap another's. Overlap means a change-spec's `area` is ambiguous.",
|
|
2310
|
+
check(a, world) {
|
|
2311
|
+
const others = (world.byType.get("module-spec") ?? []).filter((m) => m !== a);
|
|
2312
|
+
const mine = arr(a.fm.paths).filter(present).map(String);
|
|
2313
|
+
const out = [];
|
|
2314
|
+
for (const other of others) {
|
|
2315
|
+
// Report each pair once, from the alphabetically-first module.
|
|
2316
|
+
if (String(a.fm.id) > String(other.fm.id)) continue;
|
|
2317
|
+
for (const p of mine) {
|
|
2318
|
+
for (const q of arr(other.fm.paths).filter(present).map(String)) {
|
|
2319
|
+
if (globsOverlap(p, q)) {
|
|
2320
|
+
out.push(
|
|
2321
|
+
F(
|
|
2322
|
+
`paths entry \`${p}\` overlaps \`${q}\` in ${other.relpath} — module-to-paths mapping is ambiguous`,
|
|
2323
|
+
a.lineOf("paths"),
|
|
2324
|
+
),
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
return out;
|
|
2331
|
+
},
|
|
2332
|
+
},
|
|
2333
|
+
{
|
|
2334
|
+
id: "MS-03",
|
|
2335
|
+
type: "module-spec",
|
|
2336
|
+
description: "The Invariants section names at least three invariants.",
|
|
2337
|
+
check(a) {
|
|
2338
|
+
if (String(a.fm.status) === "drafted" || String(a.fm.status) === "archived") return [];
|
|
2339
|
+
const sec = findSection(a.sections, "Invariants");
|
|
2340
|
+
if (!sec) return [F("no `## Invariants` section")];
|
|
2341
|
+
const n = bulletItems(sec).length;
|
|
2342
|
+
if (n < 3) return [F(`Invariants has ${n} bullet(s); MS-03 requires at least 3`, sec.line)];
|
|
2343
|
+
return [];
|
|
2344
|
+
},
|
|
2345
|
+
},
|
|
2346
|
+
|
|
2347
|
+
// -- UI: ui-brief ----------------------------------------------------------
|
|
2348
|
+
{
|
|
2349
|
+
id: "UI-01",
|
|
2350
|
+
type: "ui-brief",
|
|
2351
|
+
description:
|
|
2352
|
+
"Every `new-components` entry has a justification subsection under New Components.",
|
|
2353
|
+
check(a) {
|
|
2354
|
+
const comps = arr(a.fm["new-components"]).filter(present).map(String);
|
|
2355
|
+
if (comps.length === 0) return [];
|
|
2356
|
+
const sec = findSection(a.sections, "New Components");
|
|
2357
|
+
if (!sec) {
|
|
2358
|
+
return [F("`new-components` is non-empty but there is no New Components section", a.lineOf("new-components"))];
|
|
2359
|
+
}
|
|
2360
|
+
const text = sectionText(sec);
|
|
2361
|
+
const out = [];
|
|
2362
|
+
for (const c of comps) {
|
|
2363
|
+
// Component ids are sometimes path-qualified (`orchestrator/Foo`) while
|
|
2364
|
+
// the justification heading names the component alone.
|
|
2365
|
+
const leaf = c.split("/").pop();
|
|
2366
|
+
if (!text.includes(c) && !(leaf && text.includes(leaf))) {
|
|
2367
|
+
out.push(F(`new component \`${c}\` has no justification in the New Components section`, sec.line));
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return out;
|
|
2371
|
+
},
|
|
2372
|
+
},
|
|
2373
|
+
{
|
|
2374
|
+
id: "UI-02",
|
|
2375
|
+
type: "ui-brief",
|
|
2376
|
+
description:
|
|
2377
|
+
"`design-system-version` matches the value declared in `hstack/config.yaml`. Skipped silently when the config declares no version.",
|
|
2378
|
+
check(a, world) {
|
|
2379
|
+
const want = world.designSystemVersion;
|
|
2380
|
+
if (!present(want)) return [];
|
|
2381
|
+
const got = a.fm["design-system-version"];
|
|
2382
|
+
if (!present(got)) {
|
|
2383
|
+
return [F("`design-system-version` is null", a.lineOf("design-system-version"))];
|
|
2384
|
+
}
|
|
2385
|
+
if (String(got) !== String(want)) {
|
|
2386
|
+
return [
|
|
2387
|
+
F(
|
|
2388
|
+
`design-system-version \`${got}\` does not match hstack/config.yaml (\`${want}\`)`,
|
|
2389
|
+
a.lineOf("design-system-version"),
|
|
2390
|
+
),
|
|
2391
|
+
];
|
|
2392
|
+
}
|
|
2393
|
+
return [];
|
|
2394
|
+
},
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
id: "UI-02",
|
|
2398
|
+
variant: "figma-handoff",
|
|
2399
|
+
type: "figma-handoff",
|
|
2400
|
+
description:
|
|
2401
|
+
"A figma-handoff's `design-system-version` matches its parent ui-brief.",
|
|
2402
|
+
check(a, world) {
|
|
2403
|
+
const brief = siblingOfType(a, world, "ui-brief");
|
|
2404
|
+
if (!brief) return [];
|
|
2405
|
+
const got = a.fm["design-system-version"];
|
|
2406
|
+
const want = brief.fm["design-system-version"];
|
|
2407
|
+
if (!present(got) || !present(want)) return [];
|
|
2408
|
+
if (String(got) !== String(want)) {
|
|
2409
|
+
return [
|
|
2410
|
+
F(
|
|
2411
|
+
`design-system-version \`${got}\` does not match ${brief.relpath} (\`${want}\`)`,
|
|
2412
|
+
a.lineOf("design-system-version"),
|
|
2413
|
+
),
|
|
2414
|
+
];
|
|
2415
|
+
}
|
|
2416
|
+
return [];
|
|
2417
|
+
},
|
|
2418
|
+
},
|
|
2419
|
+
|
|
2420
|
+
// -- ST: story -------------------------------------------------------------
|
|
2421
|
+
{
|
|
2422
|
+
id: "ST-01",
|
|
2423
|
+
type: "story",
|
|
2424
|
+
description:
|
|
2425
|
+
"`persona` references a persona artifact that exists. Skipped when the repo keeps personas outside the tree (no persona artifacts found).",
|
|
2426
|
+
check(a, world) {
|
|
2427
|
+
const personas = world.byType.get("persona") ?? [];
|
|
2428
|
+
if (personas.length === 0) return [];
|
|
2429
|
+
const p = a.fm.persona;
|
|
2430
|
+
if (!present(p)) return [F("`persona` is null", a.lineOf("persona"))];
|
|
2431
|
+
const hit = personas.find((x) => String(x.fm.id) === String(p));
|
|
2432
|
+
if (!hit) {
|
|
2433
|
+
return [F(`persona \`${p}\` names no persona artifact on disk`, a.lineOf("persona"))];
|
|
2434
|
+
}
|
|
2435
|
+
return [];
|
|
2436
|
+
},
|
|
2437
|
+
},
|
|
2438
|
+
{
|
|
2439
|
+
id: "ST-02",
|
|
2440
|
+
type: "story",
|
|
2441
|
+
description: "`linked-change-specs` is non-empty once the story is `in-flight` or later.",
|
|
2442
|
+
check(a) {
|
|
2443
|
+
if (!["in-flight", "shipped"].includes(String(a.fm.status))) return [];
|
|
2444
|
+
if (arr(a.fm["linked-change-specs"]).filter(present).length > 0) return [];
|
|
2445
|
+
return [
|
|
2446
|
+
F(
|
|
2447
|
+
`status is \`${a.fm.status}\` but \`linked-change-specs\` is empty`,
|
|
2448
|
+
a.lineOf("linked-change-specs"),
|
|
2449
|
+
),
|
|
2450
|
+
];
|
|
2451
|
+
},
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
id: "ST-03",
|
|
2455
|
+
type: "story",
|
|
2456
|
+
description: "`success-metric` is a non-empty string.",
|
|
2457
|
+
check(a) {
|
|
2458
|
+
if (present(a.fm["success-metric"])) return [];
|
|
2459
|
+
return [F("`success-metric` is empty", a.lineOf("success-metric"))];
|
|
2460
|
+
},
|
|
2461
|
+
},
|
|
2462
|
+
|
|
2463
|
+
// -- INF: infrastructure ---------------------------------------------------
|
|
2464
|
+
{
|
|
2465
|
+
id: "INF-01",
|
|
2466
|
+
type: "infrastructure",
|
|
2467
|
+
description: "Every H2 named in the infrastructure template is present.",
|
|
2468
|
+
check(a) {
|
|
2469
|
+
const missing = INFRA_SECTIONS.filter((t) => !findSection(a.sections, t));
|
|
2470
|
+
if (missing.length === 0) return [];
|
|
2471
|
+
return [F(`missing required section(s): ${missing.join(", ")}`)];
|
|
2472
|
+
},
|
|
2473
|
+
},
|
|
2474
|
+
{
|
|
2475
|
+
id: "INF-02",
|
|
2476
|
+
type: "infrastructure",
|
|
2477
|
+
description: "The Unknowns section is present even when empty.",
|
|
2478
|
+
check(a) {
|
|
2479
|
+
if (findSection(a.sections, "Unknowns")) return [];
|
|
2480
|
+
return [F("no `## Unknowns` section — it is required even when empty")];
|
|
2481
|
+
},
|
|
2482
|
+
},
|
|
2483
|
+
{
|
|
2484
|
+
id: "INF-03",
|
|
2485
|
+
type: "infrastructure",
|
|
2486
|
+
description:
|
|
2487
|
+
"The Blast-Radius Matrix lists at least one row once status is `current`.",
|
|
2488
|
+
check(a) {
|
|
2489
|
+
if (String(a.fm.status) !== "current") return [];
|
|
2490
|
+
const sec = findSection(a.sections, "Blast-Radius Matrix");
|
|
2491
|
+
if (!sec) return [F("no `## Blast-Radius Matrix` section")];
|
|
2492
|
+
const rows = substantiveLines(sec).filter((l) => /^\s*\|/.test(l) && !/^\s*\|[\s|:-]*\|\s*$/.test(l));
|
|
2493
|
+
// Drop the header row.
|
|
2494
|
+
if (rows.length <= 1) {
|
|
2495
|
+
return [F("Blast-Radius Matrix has no data rows at `status: current`", sec.line)];
|
|
2496
|
+
}
|
|
2497
|
+
return [];
|
|
2498
|
+
},
|
|
2499
|
+
},
|
|
2500
|
+
|
|
2501
|
+
// -- FL: kernel-fit flags --------------------------------------------------
|
|
2502
|
+
{
|
|
2503
|
+
id: "FL-01",
|
|
2504
|
+
type: "kernel-fit-flag",
|
|
2505
|
+
description:
|
|
2506
|
+
"Every pin-time field is non-null at `status: pending`: session-id, session-transcript-path, branch, head, workspace, timestamp, pre-compaction-message-count. `hint` is optional, and when set is one whitespace-free token of at most 32 characters — a real format constraint on a pointer token, mechanized here rather than left as prose in `/hstack:flag` (ADR-0014).",
|
|
2507
|
+
check(a) {
|
|
2508
|
+
const fields = [
|
|
2509
|
+
"session-id",
|
|
2510
|
+
"session-transcript-path",
|
|
2511
|
+
"branch",
|
|
2512
|
+
"head",
|
|
2513
|
+
"workspace",
|
|
2514
|
+
"timestamp",
|
|
2515
|
+
"pre-compaction-message-count",
|
|
2516
|
+
];
|
|
2517
|
+
const out = fields
|
|
2518
|
+
.filter((f) => !present(a.fm[f]))
|
|
2519
|
+
.map((f) => F(`pin-time field \`${f}\` is null`, a.lineOf(f)));
|
|
2520
|
+
const hint = a.fm.hint;
|
|
2521
|
+
if (present(hint)) {
|
|
2522
|
+
const h = String(hint);
|
|
2523
|
+
if (/\s/.test(h)) {
|
|
2524
|
+
out.push(F(`hint \`${h}\` carries whitespace; the pin stores the first token only`, a.lineOf("hint")));
|
|
2525
|
+
} else if (h.length > 32) {
|
|
2526
|
+
out.push(F(`hint is ${h.length} chars; the pin caps it at 32`, a.lineOf("hint")));
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
return out;
|
|
2530
|
+
},
|
|
2531
|
+
},
|
|
2532
|
+
{
|
|
2533
|
+
id: "FL-02",
|
|
2534
|
+
type: "kernel-fit-flag",
|
|
2535
|
+
description:
|
|
2536
|
+
"`status: processed` requires `classification` and `classification-rationale` non-null.",
|
|
2537
|
+
check(a) {
|
|
2538
|
+
if (String(a.fm.status) !== "processed") return [];
|
|
2539
|
+
return ["classification", "classification-rationale"]
|
|
2540
|
+
.filter((f) => !present(a.fm[f]))
|
|
2541
|
+
.map((f) => F(`status is \`processed\` but \`${f}\` is null`, a.lineOf(f)));
|
|
2542
|
+
},
|
|
2543
|
+
},
|
|
2544
|
+
|
|
2545
|
+
// -- CM: coord-messages ----------------------------------------------------
|
|
2546
|
+
{
|
|
2547
|
+
id: "CM-01",
|
|
2548
|
+
type: "coord-message",
|
|
2549
|
+
description:
|
|
2550
|
+
"`from-repo`, `from-branch`, `to-repo`, and `subject` are non-null at send-time; `subject` is at most 80 characters.",
|
|
2551
|
+
check(a) {
|
|
2552
|
+
const out = ["from-repo", "from-branch", "to-repo", "subject"]
|
|
2553
|
+
.filter((f) => !present(a.fm[f]))
|
|
2554
|
+
.map((f) => F(`\`${f}\` is null at send-time`, a.lineOf(f)));
|
|
2555
|
+
// CM-01's stated text is the non-null requirement; `<= 80 chars` is the
|
|
2556
|
+
// template's shape note for the same field, so it surfaces as a warning.
|
|
2557
|
+
const subject = a.fm.subject;
|
|
2558
|
+
if (present(subject) && String(subject).length > 80) {
|
|
2559
|
+
out.push(
|
|
2560
|
+
F(
|
|
2561
|
+
`subject is ${String(subject).length} chars; the template caps it at 80`,
|
|
2562
|
+
a.lineOf("subject"),
|
|
2563
|
+
"warn",
|
|
2564
|
+
),
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
return out;
|
|
2568
|
+
},
|
|
2569
|
+
},
|
|
2570
|
+
|
|
2571
|
+
// -- Body structure --------------------------------------------------------
|
|
2572
|
+
{
|
|
2573
|
+
id: "SP-06",
|
|
2574
|
+
variant: "required-sections",
|
|
2575
|
+
type: "change-spec",
|
|
2576
|
+
description:
|
|
2577
|
+
"The change-spec's contract sections carry content once the spec is past draft: Problem, Current Behavior, Target Behavior, Acceptance Criteria, Surfaces. (Invariants is SP-04, Scope Boundaries is the SP-06 primary check.)",
|
|
2578
|
+
check(a) {
|
|
2579
|
+
if (!CHANGE_SPEC_BEYOND_DRAFT(a.fm.status)) return [];
|
|
2580
|
+
const out = requireSections(a, [
|
|
2581
|
+
"Problem",
|
|
2582
|
+
"Current Behavior",
|
|
2583
|
+
"Target Behavior",
|
|
2584
|
+
"Acceptance Criteria",
|
|
2585
|
+
"Surfaces",
|
|
2586
|
+
]);
|
|
2587
|
+
if (arr(a.fm["resolves-tech-debt"]).filter(present).length > 0) {
|
|
2588
|
+
const sec = findSection(a.sections, "Resolves Tech-Debt");
|
|
2589
|
+
if (!sec || substantiveLines(sec).length === 0) {
|
|
2590
|
+
out.push(
|
|
2591
|
+
F(
|
|
2592
|
+
"`resolves-tech-debt` is non-empty but the Resolves Tech-Debt section is empty (it must quote each TD's Acceptance verbatim)",
|
|
2593
|
+
),
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
return out;
|
|
2598
|
+
},
|
|
2599
|
+
},
|
|
2600
|
+
];
|
|
2601
|
+
|
|
2602
|
+
const HARDENING_LAYERS = [
|
|
2603
|
+
"data-at-rest",
|
|
2604
|
+
"data-in-transit",
|
|
2605
|
+
"authn",
|
|
2606
|
+
"authz-rls",
|
|
2607
|
+
"tenant-isolation",
|
|
2608
|
+
"input-validation",
|
|
2609
|
+
"output-encoding",
|
|
2610
|
+
"secrets-handling",
|
|
2611
|
+
"agent-prompt-injection",
|
|
2612
|
+
"audit-logging",
|
|
2613
|
+
];
|
|
2614
|
+
|
|
2615
|
+
const AR_CATEGORIES = [
|
|
2616
|
+
"security",
|
|
2617
|
+
"scope-drift",
|
|
2618
|
+
"invariant-breach",
|
|
2619
|
+
"spec-compliance",
|
|
2620
|
+
"data-integrity",
|
|
2621
|
+
"code-quality",
|
|
2622
|
+
];
|
|
2623
|
+
|
|
2624
|
+
const AR_SEVERITIES = ["critical", "high", "medium", "low"];
|
|
2625
|
+
|
|
2626
|
+
const INFRA_SECTIONS = [
|
|
2627
|
+
"Hosting & Compute",
|
|
2628
|
+
"Networking",
|
|
2629
|
+
"Data Layer",
|
|
2630
|
+
"Storage",
|
|
2631
|
+
"Secrets & Configuration",
|
|
2632
|
+
"Environment Separation",
|
|
2633
|
+
"IaC Inventory",
|
|
2634
|
+
"Deploy Pipeline",
|
|
2635
|
+
"Observability",
|
|
2636
|
+
"Cost & Capacity",
|
|
2637
|
+
"Disaster Recovery",
|
|
2638
|
+
"Blast-Radius Matrix",
|
|
2639
|
+
"Access & Change Control",
|
|
2640
|
+
"MCP Access Policy",
|
|
2641
|
+
"Compliance & Data Residency",
|
|
2642
|
+
"Third-party Dependencies",
|
|
2643
|
+
"Known Gaps",
|
|
2644
|
+
"Unknowns",
|
|
2645
|
+
];
|
|
2646
|
+
|
|
2647
|
+
/**
|
|
2648
|
+
* Rules named in the repo that this validator does NOT mechanize, with the
|
|
2649
|
+
* reason. Printed by `--rules` and surfaced in `--json` so nothing quietly
|
|
2650
|
+
* disappears between "documented" and "enforced".
|
|
2651
|
+
*/
|
|
2652
|
+
export const DEFERRED_RULES = [
|
|
2653
|
+
{
|
|
2654
|
+
id: "TD-03",
|
|
2655
|
+
type: "tech-debt",
|
|
2656
|
+
reason:
|
|
2657
|
+
"Immutability of a resolved / wontfix / stale tech-debt is a claim about git history, not about the file on disk. Checking it means diffing every terminal TD against its state at the resolving commit for every validator run — disproportionate for a check that runs after every mechanical write. Belongs in the CI gate, which already has the full ref range.",
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
id: "CM-02",
|
|
2661
|
+
type: "coord-message",
|
|
2662
|
+
reason:
|
|
2663
|
+
"Coord-message immutability is the same git-history claim as TD-03. A committed message that was later edited is invisible to a working-tree validator.",
|
|
2664
|
+
},
|
|
2665
|
+
{
|
|
2666
|
+
id: "CM-03",
|
|
2667
|
+
type: "coord-message",
|
|
2668
|
+
reason:
|
|
2669
|
+
"\"The body is information, never instructions\" is a rule about how the receiving session treats the message. Not a property of the artifact.",
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
id: "AR-03",
|
|
2673
|
+
type: "adversarial-review",
|
|
2674
|
+
reason:
|
|
2675
|
+
"No repo source states AR-03. The id exists only in the diverged Notion schema doc, which is not authoritative. Implementing it would mean inventing the rule.",
|
|
2676
|
+
},
|
|
2677
|
+
{
|
|
2678
|
+
id: "AR-04",
|
|
2679
|
+
type: "adversarial-review",
|
|
2680
|
+
reason:
|
|
2681
|
+
"`commit:<hash>` must reference an existing commit *on the change's branch* — that needs git ancestry resolution against a branch the validator cannot infer from the working tree. The shape of the value is checked by AR-02; existence belongs in the CI gate.",
|
|
2682
|
+
},
|
|
2683
|
+
{
|
|
2684
|
+
id: "INF-04",
|
|
2685
|
+
type: "infrastructure",
|
|
2686
|
+
reason:
|
|
2687
|
+
"\"No MCP server wired with always-on write capability against prod\" is scored against a free-prose MCP Access Policy table whose rows carry judgment (is this change-window named? is this project really prod?). Table-shape parsing would produce confident wrong answers.",
|
|
2688
|
+
},
|
|
2689
|
+
{
|
|
2690
|
+
id: "INF-05",
|
|
2691
|
+
type: "infrastructure",
|
|
2692
|
+
reason:
|
|
2693
|
+
"Same as INF-04: the session-pattern compliance table is a judgment record, and the rule itself constrains live sessions, not the artifact.",
|
|
2694
|
+
},
|
|
2695
|
+
{
|
|
2696
|
+
id: "CG-01..CG-04",
|
|
2697
|
+
type: "cross-reference",
|
|
2698
|
+
reason:
|
|
2699
|
+
"The repo names the range (`GT-09: every cross-reference rule (CG-01..CG-04) passes`) but no source states what the four rules are. Implementing them means inventing them. Assigning the statements is a kernel change, not a validator change — and having survived two enforcement passes undefined, defining them is a kernel-fit candidate rather than a TODO. `compute-merge-readiness.mjs` reports GT-09 as `deferred` with the same reason.",
|
|
2700
|
+
},
|
|
2701
|
+
{
|
|
2702
|
+
id: "GT-01..GT-12",
|
|
2703
|
+
type: "merge-gate",
|
|
2704
|
+
reason:
|
|
2705
|
+
"Merge-readiness gates read the PR diff and the git branch, not the artifact tree, so they are not validator rules. They live in `hstack/scripts/compute-merge-readiness.mjs` (and, for GT-03, `run-gates.sh`), which has its own registry — `--gates` prints it. GT-08 restates SP-09 and GT-12 restates SP-13/SP-14, and those halves are ALSO enforced here, against the whole tree rather than one change.",
|
|
2706
|
+
},
|
|
2707
|
+
{
|
|
2708
|
+
id: "SP-01..SP-03, SP-07, SP-08, SP-10..SP-12",
|
|
2709
|
+
type: "change-spec",
|
|
2710
|
+
reason:
|
|
2711
|
+
"These ids are not named in any repo source (kernel, templates, Skills, subagents, ADRs). They exist only in the diverged Notion schema doc. No id squatting: they stay unimplemented until a kernel change states them.",
|
|
2712
|
+
},
|
|
2713
|
+
{
|
|
2714
|
+
id: "judgment-rules",
|
|
2715
|
+
type: "*",
|
|
2716
|
+
reason:
|
|
2717
|
+
"Quality rules stay with the subagents: whether a challenge-prompt answer actually probes for omissions, whether an adversarial finding is real or filler, whether a severity is calibrated, whether a counter-explanation genuinely weakens its finding, whether a rationale paragraph is honest about a degraded read source. A validator that scored these would be an LLM, and the kernel already has one in the loop.",
|
|
2718
|
+
},
|
|
2719
|
+
];
|
|
2720
|
+
|
|
2721
|
+
// ---------------------------------------------------------------------------
|
|
2722
|
+
// 5. Rule helpers
|
|
2723
|
+
// ---------------------------------------------------------------------------
|
|
2724
|
+
|
|
2725
|
+
function isRealDate(s) {
|
|
2726
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
2727
|
+
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
|
|
2728
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
2729
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function adrNumber(id) {
|
|
2733
|
+
const m = /^ADR-(\d{4})/.exec(String(id ?? ""));
|
|
2734
|
+
return m ? Number(m[1]) : null;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
function pad4(n) {
|
|
2738
|
+
return String(n).padStart(4, "0");
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function isTerminal(a, terminals) {
|
|
2742
|
+
return terminals.includes(String(a.fm.status));
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function requireSections(a, titles) {
|
|
2746
|
+
const out = [];
|
|
2747
|
+
for (const t of titles) {
|
|
2748
|
+
const sec = findSection(a.sections, t);
|
|
2749
|
+
if (!sec) out.push(F(`missing required section \`## ${t}\``));
|
|
2750
|
+
else if (substantiveLines(sec).length === 0) {
|
|
2751
|
+
out.push(F(`section \`## ${t}\` carries no content`, sec.line));
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
return out;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** The change-spec that owns the folder this per-change artifact lives in. */
|
|
2758
|
+
function parentSpec(a, world) {
|
|
2759
|
+
const sibling = world.byPath.get(join(dirname(a.path), "spec.md"));
|
|
2760
|
+
if (sibling && sibling.type === "change-spec") return sibling;
|
|
2761
|
+
const byId = world.byId.get(String(a.fm["parent-change"]));
|
|
2762
|
+
return byId && byId.type === "change-spec" ? byId : null;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function siblingOfType(a, world, type) {
|
|
2766
|
+
for (const other of world.artifacts) {
|
|
2767
|
+
if (other !== a && other.type === type && dirname(other.path) === dirname(a.path)) {
|
|
2768
|
+
return other;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
function parentChangeMatches(a, world) {
|
|
2775
|
+
const sibling = world.byPath.get(join(dirname(a.path), "spec.md"));
|
|
2776
|
+
if (!sibling || sibling.type !== "change-spec") return [];
|
|
2777
|
+
if (String(a.fm["parent-change"]) !== String(sibling.fm.id)) {
|
|
2778
|
+
return [
|
|
2779
|
+
F(
|
|
2780
|
+
`parent-change is \`${a.fm["parent-change"]}\` but the enclosing change folder holds \`${sibling.fm.id}\``,
|
|
2781
|
+
a.lineOf("parent-change"),
|
|
2782
|
+
),
|
|
2783
|
+
];
|
|
2784
|
+
}
|
|
2785
|
+
return [];
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
function isValidResolution(s) {
|
|
2789
|
+
if (s === "justified-in-prose") return true;
|
|
2790
|
+
if (/^commit:[0-9a-f]{7,40}$/i.test(s)) return true;
|
|
2791
|
+
if (/^tech-debt:\S+$/.test(s)) return true;
|
|
2792
|
+
return false;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* Table names declared in the data-review Schema Changes section.
|
|
2797
|
+
*
|
|
2798
|
+
* The section is prose, so this reads only the identifier that immediately
|
|
2799
|
+
* follows a "new table" phrase — never every backticked token on the line,
|
|
2800
|
+
* which sweeps up column names. DR-01 reports as a warning because of it.
|
|
2801
|
+
*/
|
|
2802
|
+
function newTablesFromSchemaChanges(a) {
|
|
2803
|
+
const sec = findSection(a.sections, "Schema Changes");
|
|
2804
|
+
if (!sec) return [];
|
|
2805
|
+
const out = new Set();
|
|
2806
|
+
for (const line of substantiveLines(sec)) {
|
|
2807
|
+
for (const m of line.matchAll(
|
|
2808
|
+
/new\s+table[^`\n]{0,30}?`([a-z_][a-z0-9_]*)`/gi,
|
|
2809
|
+
)) {
|
|
2810
|
+
out.add(m[1]);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
return [...out];
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
/** Phase ids declared in a plan's Phase Overview table and Per-Phase Detail. */
|
|
2817
|
+
function planPhases(a) {
|
|
2818
|
+
const overview = [];
|
|
2819
|
+
const overviewSec = findSection(a.sections, "Phase Overview");
|
|
2820
|
+
if (overviewSec) {
|
|
2821
|
+
for (const line of overviewSec.lines) {
|
|
2822
|
+
const m = /^\s*\|\s*`?([a-z0-9][a-z0-9-]*)`?\s*\|/.exec(line);
|
|
2823
|
+
if (m && m[1] !== "step-id" && !/^-+$/.test(m[1])) overview.push(m[1]);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
const detailSec = findSection(a.sections, "Per-Phase Detail");
|
|
2827
|
+
const detail = [];
|
|
2828
|
+
if (detailSec) {
|
|
2829
|
+
const start = a.sections.indexOf(detailSec);
|
|
2830
|
+
for (let i = start + 1; i < a.sections.length; i++) {
|
|
2831
|
+
const s = a.sections[i];
|
|
2832
|
+
if (s.level <= 2) break;
|
|
2833
|
+
if (s.level === 3) detail.push(s.title.replace(/[`*]/g, "").trim());
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
return { overview, detail };
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
/** `{ phase, path, line }` for every Files Touched bullet in a plan. */
|
|
2840
|
+
function filesTouched(a) {
|
|
2841
|
+
const out = [];
|
|
2842
|
+
const detailSec = findSection(a.sections, "Per-Phase Detail");
|
|
2843
|
+
if (!detailSec) return out;
|
|
2844
|
+
const start = a.sections.indexOf(detailSec);
|
|
2845
|
+
for (let i = start + 1; i < a.sections.length; i++) {
|
|
2846
|
+
const s = a.sections[i];
|
|
2847
|
+
if (s.level <= 2) break;
|
|
2848
|
+
if (s.level !== 3) continue;
|
|
2849
|
+
const phase = s.title.replace(/[`*]/g, "").trim();
|
|
2850
|
+
let inFiles = false;
|
|
2851
|
+
s.lines.forEach((line, idx) => {
|
|
2852
|
+
if (/^\s*\*\*Files Touched\.?\*\*/i.test(line)) {
|
|
2853
|
+
inFiles = true;
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
if (inFiles && /^\s*\*\*[A-Z]/.test(line)) {
|
|
2857
|
+
inFiles = false;
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
if (!inFiles) return;
|
|
2861
|
+
const m = /^\s*[-*]\s+`?([^`\s][^`]*?)`?\s*$/.exec(line);
|
|
2862
|
+
if (!m) return;
|
|
2863
|
+
const path = m[1].trim();
|
|
2864
|
+
if (path === "" || /\s/.test(path)) return; // prose bullet, not a path
|
|
2865
|
+
out.push({ phase, path, line: s.line + idx + 1 });
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
return out;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
/** Does a repo-relative glob cover `path`? Supports `*`, `**`, and bare dirs. */
|
|
2872
|
+
export function globMatches(glob, path) {
|
|
2873
|
+
const g = glob.replace(/^\.\//, "").replace(/\/$/, "");
|
|
2874
|
+
const p = path.replace(/^\.\//, "");
|
|
2875
|
+
if (g === p) return true;
|
|
2876
|
+
if (!g.includes("*")) {
|
|
2877
|
+
// A bare directory covers everything under it.
|
|
2878
|
+
return p.startsWith(g + "/");
|
|
2879
|
+
}
|
|
2880
|
+
const re = new RegExp(
|
|
2881
|
+
"^" +
|
|
2882
|
+
g
|
|
2883
|
+
.split(/(\*\*\/|\*\*|\*)/)
|
|
2884
|
+
.map((part) => {
|
|
2885
|
+
if (part === "**/") return "(?:.*/)?";
|
|
2886
|
+
if (part === "**") return ".*";
|
|
2887
|
+
if (part === "*") return "[^/]*";
|
|
2888
|
+
return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2889
|
+
})
|
|
2890
|
+
.join("") +
|
|
2891
|
+
"$",
|
|
2892
|
+
);
|
|
2893
|
+
return re.test(p);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
/**
|
|
2897
|
+
* Do two globs overlap? Answered by expanding each glob into a probe path and
|
|
2898
|
+
* testing it against the other glob's matcher — `services/agents/**` and
|
|
2899
|
+
* `services/board.ts` share a directory prefix but cannot match the same file,
|
|
2900
|
+
* and a prefix test would wrongly call that an overlap.
|
|
2901
|
+
*/
|
|
2902
|
+
export function globsOverlap(a, b) {
|
|
2903
|
+
const na = normGlob(a);
|
|
2904
|
+
const nb = normGlob(b);
|
|
2905
|
+
if (na === nb) return true;
|
|
2906
|
+
return globMatches(na, globProbe(nb)) || globMatches(nb, globProbe(na));
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
function normGlob(g) {
|
|
2910
|
+
return g.replace(/^\.\//, "").replace(/\/$/, "");
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
/** A concrete path the glob would match, using sentinels no real path carries. */
|
|
2914
|
+
function globProbe(g) {
|
|
2915
|
+
return normGlob(g)
|
|
2916
|
+
.split("**")
|
|
2917
|
+
.join("\u0001")
|
|
2918
|
+
.split("*")
|
|
2919
|
+
.join("\u0002")
|
|
2920
|
+
.split("\u0001")
|
|
2921
|
+
.join("hstackseg/hstackseg")
|
|
2922
|
+
.split("\u0002")
|
|
2923
|
+
.join("hstacktok");
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** Does a module-spec `paths` glob resolve to anything in the repo? */
|
|
2927
|
+
function pathGlobResolves(repoRoot, glob) {
|
|
2928
|
+
const g = glob.replace(/^\.\//, "").replace(/\/$/, "");
|
|
2929
|
+
const literal = g.split("*")[0];
|
|
2930
|
+
const base = literal.endsWith("/") ? literal.slice(0, -1) : dirname(literal);
|
|
2931
|
+
if (!g.includes("*")) return existsSync(resolve(repoRoot, g));
|
|
2932
|
+
const dir = resolve(repoRoot, base === "." ? "" : base);
|
|
2933
|
+
if (!existsSync(dir)) return false;
|
|
2934
|
+
if (!g.includes("/")) return true;
|
|
2935
|
+
// Cheap: the concrete prefix directory exists, so the glob has somewhere to match.
|
|
2936
|
+
return true;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// ---------------------------------------------------------------------------
|
|
2940
|
+
// 6. Artifact discovery
|
|
2941
|
+
// ---------------------------------------------------------------------------
|
|
2942
|
+
|
|
2943
|
+
const SKIP_DIRS = new Set([
|
|
2944
|
+
"node_modules",
|
|
2945
|
+
".git",
|
|
2946
|
+
".telemetry",
|
|
2947
|
+
"__pycache__",
|
|
2948
|
+
".session-state",
|
|
2949
|
+
]);
|
|
2950
|
+
|
|
2951
|
+
/** Subtrees of `hstack/` that are framework files, not artifacts. */
|
|
2952
|
+
const SKIP_HSTACK_SUBTREES = ["templates", ".claude", "scripts", "lints"];
|
|
2953
|
+
|
|
2954
|
+
function walkMarkdown(dir, out = []) {
|
|
2955
|
+
let entries;
|
|
2956
|
+
try {
|
|
2957
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2958
|
+
} catch {
|
|
2959
|
+
return out;
|
|
2960
|
+
}
|
|
2961
|
+
for (const e of entries) {
|
|
2962
|
+
if (e.name.startsWith(".") && e.name !== ".") {
|
|
2963
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
2964
|
+
}
|
|
2965
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
2966
|
+
const p = join(dir, e.name);
|
|
2967
|
+
if (e.isDirectory()) walkMarkdown(p, out);
|
|
2968
|
+
else if (e.isFile() && e.name.endsWith(".md")) out.push(p);
|
|
2969
|
+
}
|
|
2970
|
+
return out;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
/** Read one file into an artifact record, or `null` when it carries no type. */
|
|
2974
|
+
export function loadArtifact(path, repoRoot) {
|
|
2975
|
+
let text;
|
|
2976
|
+
try {
|
|
2977
|
+
text = readFileSync(path, "utf8");
|
|
2978
|
+
} catch (err) {
|
|
2979
|
+
return { path, relpath: relative(repoRoot, path), unreadable: String(err) };
|
|
2980
|
+
}
|
|
2981
|
+
const fm = parseFrontmatter(text);
|
|
2982
|
+
if (!fm.found) {
|
|
2983
|
+
return {
|
|
2984
|
+
path,
|
|
2985
|
+
relpath: relative(repoRoot, path),
|
|
2986
|
+
noFrontmatter: true,
|
|
2987
|
+
parseError: fm.error,
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
const body = text.split("\n").slice(fm.endLine).join("\n");
|
|
2991
|
+
const sections = parseSections(body, fm.endLine + 1);
|
|
2992
|
+
return {
|
|
2993
|
+
path,
|
|
2994
|
+
relpath: relative(repoRoot, path),
|
|
2995
|
+
fm: fm.data,
|
|
2996
|
+
rawScalars: fm.rawScalars,
|
|
2997
|
+
keyLines: fm.keyLines,
|
|
2998
|
+
body,
|
|
2999
|
+
sections,
|
|
3000
|
+
type: fm.data.type === undefined || fm.data.type === null ? null : String(fm.data.type),
|
|
3001
|
+
lineOf(key) {
|
|
3002
|
+
return this.keyLines[key] ?? null;
|
|
3003
|
+
},
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
export function buildWorld(repoRoot, hstackRoot) {
|
|
3008
|
+
const files = walkMarkdown(hstackRoot).filter((p) => {
|
|
3009
|
+
const rel = relative(hstackRoot, p).split(sep);
|
|
3010
|
+
return !SKIP_HSTACK_SUBTREES.includes(rel[0]);
|
|
3011
|
+
});
|
|
3012
|
+
const artifacts = [];
|
|
3013
|
+
const skipped = [];
|
|
3014
|
+
for (const f of files) {
|
|
3015
|
+
const a = loadArtifact(f, repoRoot);
|
|
3016
|
+
if (a.noFrontmatter || a.unreadable || !a.type) {
|
|
3017
|
+
skipped.push(a);
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
artifacts.push(a);
|
|
3021
|
+
}
|
|
3022
|
+
const byId = new Map();
|
|
3023
|
+
const byType = new Map();
|
|
3024
|
+
const byPath = new Map();
|
|
3025
|
+
for (const a of artifacts) {
|
|
3026
|
+
if (present(a.fm.id) && !byId.has(String(a.fm.id))) byId.set(String(a.fm.id), a);
|
|
3027
|
+
if (!byType.has(a.type)) byType.set(a.type, []);
|
|
3028
|
+
byType.get(a.type).push(a);
|
|
3029
|
+
byPath.set(a.path, a);
|
|
3030
|
+
}
|
|
3031
|
+
return {
|
|
3032
|
+
repoRoot,
|
|
3033
|
+
hstackRoot,
|
|
3034
|
+
artifacts,
|
|
3035
|
+
skipped,
|
|
3036
|
+
byId,
|
|
3037
|
+
byType,
|
|
3038
|
+
byPath,
|
|
3039
|
+
designSystemVersion: readDesignSystemVersion(hstackRoot),
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/** Best-effort read of `design-system.version` from hstack/config.yaml. */
|
|
3044
|
+
function readDesignSystemVersion(hstackRoot) {
|
|
3045
|
+
const cfg = join(hstackRoot, "config.yaml");
|
|
3046
|
+
if (!existsSync(cfg)) return null;
|
|
3047
|
+
try {
|
|
3048
|
+
const text = readFileSync(cfg, "utf8");
|
|
3049
|
+
const m = /^\s*design-system-version:\s*(\S+)\s*$/m.exec(text);
|
|
3050
|
+
return m ? m[1].replace(/^["']|["']$/g, "") : null;
|
|
3051
|
+
} catch {
|
|
3052
|
+
return null;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// ---------------------------------------------------------------------------
|
|
3057
|
+
// 7. Runner
|
|
3058
|
+
// ---------------------------------------------------------------------------
|
|
3059
|
+
|
|
3060
|
+
export function validate(world, targets = null) {
|
|
3061
|
+
const findings = [];
|
|
3062
|
+
const scope = targets ?? world.artifacts;
|
|
3063
|
+
for (const a of scope) {
|
|
3064
|
+
if (a.noFrontmatter) {
|
|
3065
|
+
findings.push({
|
|
3066
|
+
rule: "FM-01",
|
|
3067
|
+
severity: "error",
|
|
3068
|
+
file: a.relpath,
|
|
3069
|
+
line: 1,
|
|
3070
|
+
message: a.parseError ?? "no YAML frontmatter block",
|
|
3071
|
+
});
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
3074
|
+
if (!TYPE_SCHEMAS[a.type]) continue; // unknown type — reported as skipped
|
|
3075
|
+
for (const rule of RULES) {
|
|
3076
|
+
if (rule.type !== "*" && rule.type !== a.type) continue;
|
|
3077
|
+
let raw;
|
|
3078
|
+
try {
|
|
3079
|
+
raw = rule.check(a, world);
|
|
3080
|
+
} catch (err) {
|
|
3081
|
+
findings.push({
|
|
3082
|
+
rule: rule.id,
|
|
3083
|
+
severity: "error",
|
|
3084
|
+
file: a.relpath,
|
|
3085
|
+
line: null,
|
|
3086
|
+
message: `validator error while checking this rule: ${err && err.message ? err.message : err}`,
|
|
3087
|
+
});
|
|
3088
|
+
continue;
|
|
3089
|
+
}
|
|
3090
|
+
for (const f of raw ?? []) {
|
|
3091
|
+
findings.push({
|
|
3092
|
+
rule: rule.id,
|
|
3093
|
+
severity: f.severity ?? rule.severity ?? "error",
|
|
3094
|
+
file: a.relpath,
|
|
3095
|
+
line: f.line ?? null,
|
|
3096
|
+
message: f.message,
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
findings.sort(
|
|
3102
|
+
(p, q) =>
|
|
3103
|
+
p.file.localeCompare(q.file) ||
|
|
3104
|
+
p.rule.localeCompare(q.rule) ||
|
|
3105
|
+
(p.line ?? 0) - (q.line ?? 0),
|
|
3106
|
+
);
|
|
3107
|
+
return findings;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// ---------------------------------------------------------------------------
|
|
3111
|
+
// 8. CLI
|
|
3112
|
+
// ---------------------------------------------------------------------------
|
|
3113
|
+
|
|
3114
|
+
/**
|
|
3115
|
+
* Markers that identify an hstack tree. `CLAUDE.md` is the pre-ADR-0010 kernel
|
|
3116
|
+
* filename — a consumer that has not run `hstack update` yet still validates.
|
|
3117
|
+
*/
|
|
3118
|
+
const HSTACK_MARKERS = ["KERNEL.md", "CLAUDE.md", "config.yaml"];
|
|
3119
|
+
|
|
3120
|
+
const looksLikeHstack = (dir) => HSTACK_MARKERS.some((m) => existsSync(join(dir, m)));
|
|
3121
|
+
|
|
3122
|
+
export function findHstackRoot(start) {
|
|
3123
|
+
let dir = resolve(start);
|
|
3124
|
+
for (;;) {
|
|
3125
|
+
if (basename(dir) === "hstack" && looksLikeHstack(dir)) {
|
|
3126
|
+
return { repoRoot: dirname(dir), hstackRoot: dir };
|
|
3127
|
+
}
|
|
3128
|
+
const nested = join(dir, "hstack");
|
|
3129
|
+
if (existsSync(nested) && looksLikeHstack(nested)) {
|
|
3130
|
+
return { repoRoot: dir, hstackRoot: nested };
|
|
3131
|
+
}
|
|
3132
|
+
const parent = dirname(dir);
|
|
3133
|
+
if (parent === dir) return null;
|
|
3134
|
+
dir = parent;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
function usage() {
|
|
3139
|
+
return `hstack validate-spec — mechanical artifact contract check
|
|
3140
|
+
|
|
3141
|
+
node hstack/scripts/validate-spec.mjs [options] [path ...]
|
|
3142
|
+
|
|
3143
|
+
(no path) validate every artifact under hstack/
|
|
3144
|
+
<path> validate the named file(s); cross-artifact rules still read the
|
|
3145
|
+
whole tree, so reciprocity is checked against real disk state
|
|
3146
|
+
|
|
3147
|
+
Options
|
|
3148
|
+
--json emit findings as JSON on stdout
|
|
3149
|
+
--strict treat warnings as failures (exit 1)
|
|
3150
|
+
--rules print the rule registry (implemented + deferred) and exit
|
|
3151
|
+
--root DIR repo root to resolve hstack/ from (default: search upward from cwd)
|
|
3152
|
+
-h, --help this text
|
|
3153
|
+
|
|
3154
|
+
Exit codes: 0 clean, 1 findings, 2 usage or environment error.`;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function printRules() {
|
|
3158
|
+
const impl = RULES.map((r) => ({
|
|
3159
|
+
id: r.id + (r.variant ? ` (${r.variant})` : ""),
|
|
3160
|
+
type: r.type,
|
|
3161
|
+
severity: r.severity ?? "error",
|
|
3162
|
+
inferred: r.inferred === true,
|
|
3163
|
+
description: r.description,
|
|
3164
|
+
}));
|
|
3165
|
+
console.log(`# hstack validator registry — ${RULES.length} checks, ${DEFERRED_RULES.length} deferred entries\n`);
|
|
3166
|
+
console.log("## Mechanized\n");
|
|
3167
|
+
for (const r of impl) {
|
|
3168
|
+
console.log(
|
|
3169
|
+
`- ${r.id} [${r.type}]${r.severity === "warn" ? " (warning)" : ""}${r.inferred ? " (inferred — no repo source states the rule text)" : ""}\n ${r.description}`,
|
|
3170
|
+
);
|
|
3171
|
+
}
|
|
3172
|
+
console.log("\n## Deferred — named, not mechanized in v1\n");
|
|
3173
|
+
for (const r of DEFERRED_RULES) {
|
|
3174
|
+
console.log(`- ${r.id} [${r.type}]\n ${r.reason}`);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
function main(argv) {
|
|
3179
|
+
const args = argv.slice(2);
|
|
3180
|
+
const opts = { json: false, strict: false, rules: false, root: null, paths: [] };
|
|
3181
|
+
for (let i = 0; i < args.length; i++) {
|
|
3182
|
+
const a = args[i];
|
|
3183
|
+
if (a === "--json") opts.json = true;
|
|
3184
|
+
else if (a === "--strict") opts.strict = true;
|
|
3185
|
+
else if (a === "--rules") opts.rules = true;
|
|
3186
|
+
else if (a === "--root") opts.root = args[++i];
|
|
3187
|
+
else if (a === "-h" || a === "--help") {
|
|
3188
|
+
console.log(usage());
|
|
3189
|
+
return 0;
|
|
3190
|
+
} else if (a.startsWith("-")) {
|
|
3191
|
+
console.error(`validate-spec: unknown option ${a}\n\n${usage()}`);
|
|
3192
|
+
return 2;
|
|
3193
|
+
} else opts.paths.push(a);
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
if (opts.rules) {
|
|
3197
|
+
printRules();
|
|
3198
|
+
return 0;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
const found = findHstackRoot(opts.root ?? (opts.paths[0] ? dirname(resolve(opts.paths[0])) : process.cwd()));
|
|
3202
|
+
if (!found) {
|
|
3203
|
+
console.error(
|
|
3204
|
+
"validate-spec: no hstack/ tree found (looked for hstack/KERNEL.md upward from " +
|
|
3205
|
+
(opts.root ?? process.cwd()) +
|
|
3206
|
+
"). Pass --root <repo>.",
|
|
3207
|
+
);
|
|
3208
|
+
return 2;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
const world = buildWorld(found.repoRoot, found.hstackRoot);
|
|
3212
|
+
|
|
3213
|
+
let targets = null;
|
|
3214
|
+
const missing = [];
|
|
3215
|
+
if (opts.paths.length > 0) {
|
|
3216
|
+
targets = [];
|
|
3217
|
+
for (const p of opts.paths) {
|
|
3218
|
+
const abs = resolve(p);
|
|
3219
|
+
const hit = world.byPath.get(abs);
|
|
3220
|
+
if (hit) targets.push(hit);
|
|
3221
|
+
else if (!existsSync(abs)) missing.push(p);
|
|
3222
|
+
else {
|
|
3223
|
+
// Outside the scanned tree, or carries no `type:` — load it directly so
|
|
3224
|
+
// a Skill can validate the file it just wrote wherever it lives.
|
|
3225
|
+
const a = loadArtifact(abs, found.repoRoot);
|
|
3226
|
+
if (a.noFrontmatter || !a.type) {
|
|
3227
|
+
missing.push(`${p} (no hstack frontmatter)`);
|
|
3228
|
+
} else {
|
|
3229
|
+
targets.push(a);
|
|
3230
|
+
world.artifacts.push(a);
|
|
3231
|
+
world.byPath.set(abs, a);
|
|
3232
|
+
if (present(a.fm.id) && !world.byId.has(String(a.fm.id))) {
|
|
3233
|
+
world.byId.set(String(a.fm.id), a);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
if (missing.length > 0) {
|
|
3240
|
+
console.error(`validate-spec: cannot validate ${missing.join(", ")}`);
|
|
3241
|
+
return 2;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
const findings = validate(world, targets);
|
|
3245
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
3246
|
+
const warnings = findings.filter((f) => f.severity === "warn");
|
|
3247
|
+
const scanned = targets ?? world.artifacts;
|
|
3248
|
+
// Only meaningful for a whole-tree run: when the engineer named paths, the
|
|
3249
|
+
// tree-wide skip list is noise about files they did not ask about.
|
|
3250
|
+
const unknownTypes = targets
|
|
3251
|
+
? []
|
|
3252
|
+
: [...new Set(world.artifacts.filter((a) => !TYPE_SCHEMAS[a.type]).map((a) => a.type))].sort();
|
|
3253
|
+
|
|
3254
|
+
if (opts.json) {
|
|
3255
|
+
console.log(
|
|
3256
|
+
JSON.stringify(
|
|
3257
|
+
{
|
|
3258
|
+
ok: errors.length === 0 && (!opts.strict || warnings.length === 0),
|
|
3259
|
+
scanned: scanned.length,
|
|
3260
|
+
errors: errors.length,
|
|
3261
|
+
warnings: warnings.length,
|
|
3262
|
+
unknownTypes,
|
|
3263
|
+
findings,
|
|
3264
|
+
deferred: DEFERRED_RULES,
|
|
3265
|
+
},
|
|
3266
|
+
null,
|
|
3267
|
+
2,
|
|
3268
|
+
),
|
|
3269
|
+
);
|
|
3270
|
+
} else {
|
|
3271
|
+
for (const f of findings) {
|
|
3272
|
+
const where = f.line ? `${f.file}:${f.line}` : f.file;
|
|
3273
|
+
const tag = f.severity === "warn" ? "warn " : "error";
|
|
3274
|
+
console.log(`${tag} ${f.rule.padEnd(6)} ${where}\n ${f.message}`);
|
|
3275
|
+
}
|
|
3276
|
+
const byRule = new Map();
|
|
3277
|
+
for (const f of findings) byRule.set(f.rule, (byRule.get(f.rule) ?? 0) + 1);
|
|
3278
|
+
if (findings.length > 0) console.log("");
|
|
3279
|
+
console.log(
|
|
3280
|
+
`validate-spec: ${scanned.length} artifact(s), ${errors.length} error(s), ${warnings.length} warning(s)` +
|
|
3281
|
+
(byRule.size > 0
|
|
3282
|
+
? `\n by rule: ${[...byRule.entries()]
|
|
3283
|
+
.sort((p, q) => q[1] - p[1])
|
|
3284
|
+
.map(([r, n]) => `${r}=${n}`)
|
|
3285
|
+
.join(" ")}`
|
|
3286
|
+
: ""),
|
|
3287
|
+
);
|
|
3288
|
+
if (unknownTypes.length > 0) {
|
|
3289
|
+
console.log(` skipped ${unknownTypes.length} unknown artifact type(s): ${unknownTypes.join(", ")}`);
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
if (errors.length > 0) return 1;
|
|
3294
|
+
if (opts.strict && warnings.length > 0) return 1;
|
|
3295
|
+
return 0;
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
// Only run the CLI when invoked directly, so `doctor` and the tests can import.
|
|
3299
|
+
const invokedDirectly =
|
|
3300
|
+
process.argv[1] && resolve(process.argv[1]).endsWith(`${sep}validate-spec.mjs`);
|
|
3301
|
+
if (invokedDirectly) {
|
|
3302
|
+
process.exit(main(process.argv));
|
|
3303
|
+
}
|