cclaw-cli 0.51.30 → 0.55.2
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/README.md +22 -16
- package/dist/artifact-linter/brainstorm.d.ts +2 -0
- package/dist/artifact-linter/brainstorm.js +245 -0
- package/dist/artifact-linter/design.d.ts +2 -0
- package/dist/artifact-linter/design.js +323 -0
- package/dist/artifact-linter/plan.d.ts +2 -0
- package/dist/artifact-linter/plan.js +162 -0
- package/dist/artifact-linter/review-army.d.ts +24 -0
- package/dist/artifact-linter/review-army.js +365 -0
- package/dist/artifact-linter/review.d.ts +2 -0
- package/dist/artifact-linter/review.js +65 -0
- package/dist/artifact-linter/scope.d.ts +2 -0
- package/dist/artifact-linter/scope.js +115 -0
- package/dist/artifact-linter/shared.d.ts +246 -0
- package/dist/artifact-linter/shared.js +1488 -0
- package/dist/artifact-linter/ship.d.ts +2 -0
- package/dist/artifact-linter/ship.js +46 -0
- package/dist/artifact-linter/spec.d.ts +2 -0
- package/dist/artifact-linter/spec.js +108 -0
- package/dist/artifact-linter/tdd.d.ts +2 -0
- package/dist/artifact-linter/tdd.js +124 -0
- package/dist/artifact-linter.d.ts +4 -76
- package/dist/artifact-linter.js +56 -2949
- package/dist/cli.d.ts +1 -6
- package/dist/cli.js +4 -159
- package/dist/codex-feature-flag.d.ts +1 -1
- package/dist/codex-feature-flag.js +1 -1
- package/dist/config.d.ts +3 -2
- package/dist/config.js +67 -3
- package/dist/constants.d.ts +1 -7
- package/dist/constants.js +9 -15
- package/dist/content/cancel-command.js +2 -2
- package/dist/content/closeout-guidance.js +10 -7
- package/dist/content/core-agents.d.ts +18 -0
- package/dist/content/core-agents.js +46 -2
- package/dist/content/decision-protocol.d.ts +1 -1
- package/dist/content/decision-protocol.js +1 -1
- package/dist/content/examples.js +6 -6
- package/dist/content/harness-doc.js +20 -2
- package/dist/content/hook-inline-snippets.d.ts +17 -4
- package/dist/content/hook-inline-snippets.js +218 -5
- package/dist/content/hook-manifest.d.ts +2 -2
- package/dist/content/hook-manifest.js +2 -2
- package/dist/content/hooks.d.ts +1 -0
- package/dist/content/hooks.js +32 -137
- package/dist/content/idea-command.d.ts +8 -0
- package/dist/content/{ideate-command.js → idea-command.js} +57 -50
- package/dist/content/idea-frames.d.ts +31 -0
- package/dist/content/{ideate-frames.js → idea-frames.js} +9 -9
- package/dist/content/idea-ranking.d.ts +25 -0
- package/dist/content/{ideate-ranking.js → idea-ranking.js} +5 -5
- package/dist/content/iron-laws.d.ts +0 -1
- package/dist/content/iron-laws.js +31 -16
- package/dist/content/learnings.js +1 -1
- package/dist/content/meta-skill.js +7 -7
- package/dist/content/node-hooks.d.ts +10 -0
- package/dist/content/node-hooks.js +43 -9
- package/dist/content/opencode-plugin.js +3 -3
- package/dist/content/skills.js +19 -7
- package/dist/content/stage-schema.js +44 -2
- package/dist/content/stages/_lint-metadata/index.js +26 -2
- package/dist/content/stages/brainstorm.js +13 -7
- package/dist/content/stages/design.js +16 -11
- package/dist/content/stages/plan.js +7 -4
- package/dist/content/stages/review.js +4 -4
- package/dist/content/stages/schema-types.d.ts +1 -1
- package/dist/content/stages/scope.js +15 -12
- package/dist/content/stages/ship.js +2 -2
- package/dist/content/stages/spec.js +9 -3
- package/dist/content/stages/tdd.js +14 -4
- package/dist/content/start-command.js +11 -10
- package/dist/content/status-command.js +3 -3
- package/dist/content/subagents.js +60 -6
- package/dist/content/templates.d.ts +1 -1
- package/dist/content/templates.js +102 -150
- package/dist/content/tree-command.js +2 -2
- package/dist/content/utility-skills.d.ts +2 -2
- package/dist/content/utility-skills.js +2 -2
- package/dist/content/view-command.js +4 -2
- package/dist/delegation.d.ts +2 -0
- package/dist/delegation.js +2 -1
- package/dist/early-loop.d.ts +66 -0
- package/dist/early-loop.js +275 -0
- package/dist/gate-evidence.d.ts +8 -0
- package/dist/gate-evidence.js +141 -5
- package/dist/harness-adapters.d.ts +2 -2
- package/dist/harness-adapters.js +47 -18
- package/dist/install.js +153 -29
- package/dist/internal/advance-stage/advance.d.ts +50 -0
- package/dist/internal/advance-stage/advance.js +480 -0
- package/dist/internal/advance-stage/cancel-run.d.ts +8 -0
- package/dist/internal/advance-stage/cancel-run.js +19 -0
- package/dist/internal/advance-stage/flow-state-coercion.d.ts +3 -0
- package/dist/internal/advance-stage/flow-state-coercion.js +81 -0
- package/dist/internal/advance-stage/helpers.d.ts +14 -0
- package/dist/internal/advance-stage/helpers.js +145 -0
- package/dist/internal/advance-stage/hook.d.ts +8 -0
- package/dist/internal/advance-stage/hook.js +40 -0
- package/dist/internal/advance-stage/parsers.d.ts +54 -0
- package/dist/internal/advance-stage/parsers.js +307 -0
- package/dist/internal/advance-stage/review-loop.d.ts +7 -0
- package/dist/internal/advance-stage/review-loop.js +170 -0
- package/dist/internal/advance-stage/rewind.d.ts +14 -0
- package/dist/internal/advance-stage/rewind.js +108 -0
- package/dist/internal/advance-stage/start-flow.d.ts +11 -0
- package/dist/internal/advance-stage/start-flow.js +136 -0
- package/dist/internal/advance-stage/verify.d.ts +29 -0
- package/dist/internal/advance-stage/verify.js +225 -0
- package/dist/internal/advance-stage.js +21 -1470
- package/dist/internal/compound-readiness.d.ts +1 -1
- package/dist/internal/compound-readiness.js +2 -2
- package/dist/internal/early-loop-status.d.ts +7 -0
- package/dist/internal/early-loop-status.js +90 -0
- package/dist/internal/runtime-integrity.d.ts +7 -0
- package/dist/internal/runtime-integrity.js +288 -0
- package/dist/internal/tdd-red-evidence.js +1 -1
- package/dist/knowledge-store.d.ts +3 -8
- package/dist/knowledge-store.js +16 -29
- package/dist/managed-resources.js +24 -2
- package/dist/policy.js +4 -6
- package/dist/run-archive.d.ts +1 -1
- package/dist/run-archive.js +12 -12
- package/dist/run-persistence.js +111 -11
- package/dist/tdd-cycle.d.ts +3 -3
- package/dist/tdd-cycle.js +1 -1
- package/dist/types.d.ts +18 -10
- package/package.json +1 -1
- package/dist/content/ideate-command.d.ts +0 -8
- package/dist/content/ideate-frames.d.ts +0 -31
- package/dist/content/ideate-ranking.d.ts +0 -25
- package/dist/content/next-command.d.ts +0 -20
- package/dist/content/next-command.js +0 -298
- package/dist/content/seed-shelf.d.ts +0 -36
- package/dist/content/seed-shelf.js +0 -301
- package/dist/content/stage-common-guidance.d.ts +0 -1
- package/dist/content/stage-common-guidance.js +0 -106
- package/dist/doctor-registry.d.ts +0 -10
- package/dist/doctor-registry.js +0 -186
- package/dist/doctor.d.ts +0 -17
- package/dist/doctor.js +0 -2201
- package/dist/internal/hook-manifest.d.ts +0 -16
- package/dist/internal/hook-manifest.js +0 -77
package/dist/artifact-linter.js
CHANGED
|
@@ -1,1433 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import path from "node:path";
|
|
4
2
|
import { resolveArtifactPath as resolveStageArtifactPath } from "./artifact-paths.js";
|
|
5
3
|
import { readConfig } from "./config.js";
|
|
6
|
-
import { RUNTIME_ROOT, SHIP_FINALIZATION_MODES } from "./constants.js";
|
|
7
4
|
import { exists } from "./fs-utils.js";
|
|
8
5
|
import { stageSchema } from "./content/stage-schema.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
* Collect H2 sections and body content (`## Section Name`).
|
|
21
|
-
*
|
|
22
|
-
* - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
|
|
23
|
-
* commented `## Approaches` inside an example doesn't open a phantom
|
|
24
|
-
* section and swallow real content.
|
|
25
|
-
* - When the same heading appears more than once at the top level we
|
|
26
|
-
* concatenate the bodies rather than silently overwriting the earlier
|
|
27
|
-
* occurrence. This keeps lint rules honest when authors split a section
|
|
28
|
-
* into multiple passes.
|
|
29
|
-
*/
|
|
30
|
-
function extractH2Sections(markdown) {
|
|
31
|
-
const sections = new Map();
|
|
32
|
-
const lines = markdown.split(/\r?\n/);
|
|
33
|
-
let currentHeading = null;
|
|
34
|
-
let buffer = [];
|
|
35
|
-
let fenced = null;
|
|
36
|
-
const flush = () => {
|
|
37
|
-
if (currentHeading === null)
|
|
38
|
-
return;
|
|
39
|
-
const existing = sections.get(currentHeading);
|
|
40
|
-
const body = buffer.join("\n");
|
|
41
|
-
sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
|
|
42
|
-
};
|
|
43
|
-
for (const line of lines) {
|
|
44
|
-
const fenceMatch = /^(```|~~~)/u.exec(line);
|
|
45
|
-
if (fenceMatch) {
|
|
46
|
-
if (fenced === null) {
|
|
47
|
-
fenced = fenceMatch[1] ?? null;
|
|
48
|
-
}
|
|
49
|
-
else if (line.startsWith(fenced)) {
|
|
50
|
-
fenced = null;
|
|
51
|
-
}
|
|
52
|
-
if (currentHeading !== null)
|
|
53
|
-
buffer.push(line);
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (fenced === null) {
|
|
57
|
-
const match = /^##\s+(.+)$/u.exec(line);
|
|
58
|
-
if (match) {
|
|
59
|
-
flush();
|
|
60
|
-
currentHeading = normalizeHeadingTitle(match[1] ?? "");
|
|
61
|
-
buffer = [];
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (currentHeading !== null) {
|
|
66
|
-
buffer.push(line);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
flush();
|
|
70
|
-
return sections;
|
|
71
|
-
}
|
|
72
|
-
function headingPresent(sections, section) {
|
|
73
|
-
const want = normalizeHeadingTitle(section).toLowerCase();
|
|
74
|
-
for (const h of sections.keys()) {
|
|
75
|
-
if (h.toLowerCase() === want) {
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
function sectionBodyByName(sections, section) {
|
|
82
|
-
const want = normalizeHeadingTitle(section).toLowerCase();
|
|
83
|
-
for (const [heading, body] of sections.entries()) {
|
|
84
|
-
if (heading.toLowerCase() === want) {
|
|
85
|
-
return body;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
function sectionBodyByAnyName(sections, sectionNames) {
|
|
91
|
-
const bodies = sectionNames.flatMap((section) => {
|
|
92
|
-
const body = sectionBodyByName(sections, section);
|
|
93
|
-
return body === null ? [] : [`### ${section}\n${body}`];
|
|
94
|
-
});
|
|
95
|
-
if (bodies.length === 0)
|
|
96
|
-
return null;
|
|
97
|
-
return bodies.join("\n");
|
|
98
|
-
}
|
|
99
|
-
function sectionBodyByHeadingPrefix(sections, prefix) {
|
|
100
|
-
const want = normalizeHeadingTitle(prefix).toLowerCase();
|
|
101
|
-
for (const [heading, body] of sections.entries()) {
|
|
102
|
-
if (heading.toLowerCase().startsWith(want)) {
|
|
103
|
-
return body;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Build a regex that matches `<field>: <value>` even when the field name
|
|
110
|
-
* and/or value are wrapped in markdown emphasis (`*`, `**`, `_`, `__`).
|
|
111
|
-
*
|
|
112
|
-
* The shipped templates render fields as `- **Field name:** value`, so any
|
|
113
|
-
* structural check that searches for `Field:\s*token` against the rendered
|
|
114
|
-
* artifact must tolerate the closing `**` between the colon and the value.
|
|
115
|
-
*
|
|
116
|
-
* `field` is treated as literal text (regex meta-characters are escaped).
|
|
117
|
-
* `value` is inserted verbatim so callers can pass alternation
|
|
118
|
-
* (`STARTUP|BUILDER|...`). `flags` defaults to case-insensitive Unicode.
|
|
119
|
-
*/
|
|
120
|
-
function markdownFieldRegex(field, value, flags = "iu") {
|
|
121
|
-
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
122
|
-
const emph = "[*_]{0,2}";
|
|
123
|
-
const source = `(?:^|[\\s>])${emph}\\s*${escapedField}\\s*${emph}\\s*:\\s*${emph}\\s*(?:${value})\\b`;
|
|
124
|
-
return new RegExp(source, flags);
|
|
125
|
-
}
|
|
126
|
-
export function extractMarkdownSectionBody(markdown, section) {
|
|
127
|
-
return sectionBodyByName(extractH2Sections(markdown), section);
|
|
128
|
-
}
|
|
129
|
-
function headingLineIndex(markdown, section) {
|
|
130
|
-
const want = normalizeHeadingTitle(section).toLowerCase();
|
|
131
|
-
const lines = markdown.split(/\r?\n/);
|
|
132
|
-
let fenced = null;
|
|
133
|
-
for (let i = 0; i < lines.length; i++) {
|
|
134
|
-
const line = lines[i];
|
|
135
|
-
const fence = /^\s*(```+|~~~+)\s*([A-Za-z0-9_-]+)?\s*$/u.exec(line);
|
|
136
|
-
if (fence) {
|
|
137
|
-
const marker = fence[1];
|
|
138
|
-
if (fenced === null) {
|
|
139
|
-
fenced = marker;
|
|
140
|
-
}
|
|
141
|
-
else if (fenced === marker) {
|
|
142
|
-
fenced = null;
|
|
143
|
-
}
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (fenced !== null)
|
|
147
|
-
continue;
|
|
148
|
-
const heading = /^##\s+(.+)$/u.exec(line);
|
|
149
|
-
if (!heading)
|
|
150
|
-
continue;
|
|
151
|
-
if (normalizeHeadingTitle(heading[1] ?? "").toLowerCase() === want) {
|
|
152
|
-
return i;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return -1;
|
|
156
|
-
}
|
|
157
|
-
function parseShortCircuitStatus(sectionBody) {
|
|
158
|
-
if (!sectionBody)
|
|
159
|
-
return "";
|
|
160
|
-
const lines = sectionBody.split(/\r?\n/u);
|
|
161
|
-
return lines
|
|
162
|
-
.map((line) => line.replace(/[*_`]/gu, "").trim())
|
|
163
|
-
.map((line) => /^[-*]?\s*status\s*:\s*(.+)$/iu.exec(line)?.[1] ?? "")
|
|
164
|
-
.find((value) => value.trim().length > 0)?.trim().toLowerCase() ?? "";
|
|
165
|
-
}
|
|
166
|
-
function isShortCircuitActivated(sectionBody) {
|
|
167
|
-
const statusValue = parseShortCircuitStatus(sectionBody);
|
|
168
|
-
return /^(?:activated|yes|true)$/u.test(statusValue) || /\bactivated\b/iu.test(statusValue);
|
|
169
|
-
}
|
|
170
|
-
const DESIGN_DIAGRAM_REQUIREMENTS = {
|
|
171
|
-
lightweight: [
|
|
172
|
-
{
|
|
173
|
-
section: "Architecture Diagram",
|
|
174
|
-
marker: "architecture",
|
|
175
|
-
note: "Architecture diagram is required for all tiers."
|
|
176
|
-
}
|
|
177
|
-
],
|
|
178
|
-
standard: [
|
|
179
|
-
{
|
|
180
|
-
section: "Architecture Diagram",
|
|
181
|
-
marker: "architecture",
|
|
182
|
-
note: "Architecture diagram is required for all tiers."
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
section: "Data-Flow Shadow Paths",
|
|
186
|
-
marker: "data-flow-shadow-paths",
|
|
187
|
-
note: "Standard+ requires data-flow shadow path coverage."
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
section: "Error Flow Diagram",
|
|
191
|
-
marker: "error-flow",
|
|
192
|
-
note: "Standard+ requires explicit error-flow rescue mapping."
|
|
193
|
-
}
|
|
194
|
-
],
|
|
195
|
-
deep: [
|
|
196
|
-
{
|
|
197
|
-
section: "Architecture Diagram",
|
|
198
|
-
marker: "architecture",
|
|
199
|
-
note: "Architecture diagram is required for all tiers."
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
section: "Data-Flow Shadow Paths",
|
|
203
|
-
marker: "data-flow-shadow-paths",
|
|
204
|
-
note: "Standard+ requires data-flow shadow path coverage."
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
section: "Error Flow Diagram",
|
|
208
|
-
marker: "error-flow",
|
|
209
|
-
note: "Standard+ requires explicit error-flow rescue mapping."
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
section: "State Machine Diagram",
|
|
213
|
-
marker: "state-machine",
|
|
214
|
-
note: "Deep tier requires state-machine coverage for lifecycle transitions."
|
|
215
|
-
},
|
|
216
|
-
{
|
|
217
|
-
section: "Rollback Flowchart",
|
|
218
|
-
marker: "rollback-flowchart",
|
|
219
|
-
note: "Deep tier requires rollback flowchart coverage."
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
section: "Deployment Sequence Diagram",
|
|
223
|
-
marker: "deployment-sequence",
|
|
224
|
-
note: "Deep tier requires deployment sequence coverage."
|
|
225
|
-
}
|
|
226
|
-
]
|
|
227
|
-
};
|
|
228
|
-
function normalizeDesignDiagramTier(value) {
|
|
229
|
-
if (!value)
|
|
230
|
-
return null;
|
|
231
|
-
const normalized = value.trim().toLowerCase();
|
|
232
|
-
if (/^(?:lite|light|lightweight)$/u.test(normalized))
|
|
233
|
-
return "lightweight";
|
|
234
|
-
if (/^standard$/u.test(normalized))
|
|
235
|
-
return "standard";
|
|
236
|
-
if (/^deep$/u.test(normalized))
|
|
237
|
-
return "deep";
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
function parseApproachTierSection(sectionBody) {
|
|
241
|
-
if (!sectionBody)
|
|
242
|
-
return null;
|
|
243
|
-
for (const line of sectionBody.split(/\r?\n/u)) {
|
|
244
|
-
const cleaned = line.replace(/[*_`]/gu, "").trim();
|
|
245
|
-
const directMatch = /(?:^|\b)tier\s*:\s*(lite|lightweight|light|standard|deep)\b/iu.exec(cleaned);
|
|
246
|
-
if (directMatch) {
|
|
247
|
-
const captured = directMatch[1] ?? "";
|
|
248
|
-
const remainder = cleaned.slice(cleaned.toLowerCase().indexOf("tier") + 4);
|
|
249
|
-
const tierTokens = remainder.match(/\b(?:lite|lightweight|light|standard|deep)\b/giu) ?? [];
|
|
250
|
-
const distinct = new Set(tierTokens.map((token) => token.toLowerCase()));
|
|
251
|
-
if (distinct.size >= 2) {
|
|
252
|
-
// Multi-token line is the unfilled template placeholder
|
|
253
|
-
// (`Tier: lite | standard | deep`); treat as no decision.
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
return normalizeDesignDiagramTier(captured);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
const token = /\b(lite|lightweight|light|standard|deep)\b/iu.exec(sectionBody)?.[1] ?? null;
|
|
260
|
-
return normalizeDesignDiagramTier(token);
|
|
261
|
-
}
|
|
262
|
-
async function resolveDesignDiagramTier(projectRoot, track, designRaw) {
|
|
263
|
-
const fromDesign = parseApproachTierSection(extractMarkdownSectionBody(designRaw, "Approach Tier"));
|
|
264
|
-
if (fromDesign) {
|
|
265
|
-
return { tier: fromDesign, source: "design-artifact:Approach Tier" };
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
const brainstormArtifact = await resolveStageArtifactPath("brainstorm", {
|
|
269
|
-
projectRoot,
|
|
270
|
-
track,
|
|
271
|
-
intent: "read"
|
|
272
|
-
});
|
|
273
|
-
if (await exists(brainstormArtifact.absPath)) {
|
|
274
|
-
const brainstormRaw = await fs.readFile(brainstormArtifact.absPath, "utf8");
|
|
275
|
-
const fromBrainstorm = parseApproachTierSection(extractMarkdownSectionBody(brainstormRaw, "Approach Tier"));
|
|
276
|
-
if (fromBrainstorm) {
|
|
277
|
-
return { tier: fromBrainstorm, source: "brainstorm-artifact:Approach Tier" };
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
// Ignore read/resolve errors and fall back to default tier.
|
|
283
|
-
}
|
|
284
|
-
return { tier: "standard", source: "default:standard" };
|
|
285
|
-
}
|
|
286
|
-
function meaningfulLineCount(sectionBody) {
|
|
287
|
-
return sectionBody
|
|
288
|
-
.split(/\r?\n/)
|
|
289
|
-
.map((line) => line.trim())
|
|
290
|
-
.filter((line) => line.length > 0)
|
|
291
|
-
.filter((line) => !line.startsWith("<!--"))
|
|
292
|
-
.filter((line) => !/^[-:| ]+$/u.test(line))
|
|
293
|
-
.filter((line) => /[\p{L}\p{N}]/u.test(line))
|
|
294
|
-
.length;
|
|
295
|
-
}
|
|
296
|
-
function lineHasToken(line, token) {
|
|
297
|
-
return new RegExp(`\\b${token}\\b`, "u").test(line);
|
|
298
|
-
}
|
|
299
|
-
function countListItems(sectionBody) {
|
|
300
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
301
|
-
const bullets = lines.filter((line) => /^[-*]\s+\S+/u.test(line)).length;
|
|
302
|
-
const tableRows = lines.filter((line) => /^\|.*\|$/u.test(line) && !/^\|[-:| ]+\|$/u.test(line));
|
|
303
|
-
const tableDataRows = tableRows.length > 0 ? Math.max(0, tableRows.length - 1) : 0;
|
|
304
|
-
return Math.max(bullets, tableDataRows);
|
|
305
|
-
}
|
|
306
|
-
function parseMarkdownTableRow(line) {
|
|
307
|
-
return line
|
|
308
|
-
.trim()
|
|
309
|
-
.split("|")
|
|
310
|
-
.map((cell) => cell.trim())
|
|
311
|
-
.filter((cell) => cell.length > 0);
|
|
312
|
-
}
|
|
313
|
-
function tableHeaderCells(sectionBody) {
|
|
314
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
315
|
-
const headerIndex = lines.findIndex((line) => /^\|.*\|$/u.test(line));
|
|
316
|
-
if (headerIndex < 0)
|
|
317
|
-
return null;
|
|
318
|
-
const separator = lines[headerIndex + 1];
|
|
319
|
-
if (!separator || !/^\|[-:| ]+\|$/u.test(separator)) {
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
return parseMarkdownTableRow(lines[headerIndex]);
|
|
323
|
-
}
|
|
324
|
-
function extractMinItemsFromRule(rule) {
|
|
325
|
-
const match = /at least\s+(\d+)/iu.exec(rule);
|
|
326
|
-
if (!match)
|
|
327
|
-
return null;
|
|
328
|
-
const parsed = Number.parseInt(match[1] ?? "", 10);
|
|
329
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
330
|
-
}
|
|
331
|
-
function tokensFromRule(rule) {
|
|
332
|
-
const allCaps = rule.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) ?? [];
|
|
333
|
-
if (allCaps.length > 0) {
|
|
334
|
-
return [...new Set(allCaps)];
|
|
335
|
-
}
|
|
336
|
-
if (/finalization enum token/iu.test(rule)) {
|
|
337
|
-
return [...SHIP_FINALIZATION_MODES];
|
|
338
|
-
}
|
|
339
|
-
if (/final verdict/iu.test(rule)) {
|
|
340
|
-
return ["APPROVED", "APPROVED_WITH_CONCERNS", "BLOCKED"];
|
|
341
|
-
}
|
|
342
|
-
return [];
|
|
343
|
-
}
|
|
344
|
-
const VAGUE_AC_ADJECTIVES = [
|
|
345
|
-
"fast",
|
|
346
|
-
"quick",
|
|
347
|
-
"slow",
|
|
348
|
-
"fast enough",
|
|
349
|
-
"quickly",
|
|
350
|
-
"intuitive",
|
|
351
|
-
"robust",
|
|
352
|
-
"reliable",
|
|
353
|
-
"scalable",
|
|
354
|
-
"simple",
|
|
355
|
-
"easy",
|
|
356
|
-
"user-friendly",
|
|
357
|
-
"user friendly",
|
|
358
|
-
"nice",
|
|
359
|
-
"good",
|
|
360
|
-
"clean",
|
|
361
|
-
"secure enough",
|
|
362
|
-
"responsive",
|
|
363
|
-
"efficient",
|
|
364
|
-
"performant",
|
|
365
|
-
"smooth",
|
|
366
|
-
"seamless",
|
|
367
|
-
"modern"
|
|
368
|
-
];
|
|
369
|
-
function isSeparatorRow(line) {
|
|
370
|
-
return /^\|[-:| ]+\|$/u.test(line);
|
|
371
|
-
}
|
|
372
|
-
function getMarkdownTableRows(sectionBody) {
|
|
373
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
374
|
-
const rows = [];
|
|
375
|
-
let sawSeparator = false;
|
|
376
|
-
for (const line of lines) {
|
|
377
|
-
if (!/^\|.*\|$/u.test(line))
|
|
378
|
-
continue;
|
|
379
|
-
if (isSeparatorRow(line)) {
|
|
380
|
-
sawSeparator = true;
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
if (!sawSeparator)
|
|
384
|
-
continue;
|
|
385
|
-
rows.push(parseMarkdownTableRow(line));
|
|
386
|
-
}
|
|
387
|
-
return rows;
|
|
388
|
-
}
|
|
389
|
-
function parseBinaryFlag(value) {
|
|
390
|
-
const normalized = value.trim().toLowerCase();
|
|
391
|
-
if (/^(?:y|yes|true|1)$/u.test(normalized))
|
|
392
|
-
return "yes";
|
|
393
|
-
if (/^(?:n|no|false|0|none)$/u.test(normalized))
|
|
394
|
-
return "no";
|
|
395
|
-
return "unknown";
|
|
396
|
-
}
|
|
397
|
-
function parseKeyedBinaryFlag(value, key) {
|
|
398
|
-
const match = new RegExp(`${key}\\s*=\\s*(y|yes|true|1|n|no|false|0)`, "iu").exec(value);
|
|
399
|
-
if (!match)
|
|
400
|
-
return "unknown";
|
|
401
|
-
return /^(?:y|yes|true|1)$/iu.test(match[1] ?? "") ? "yes" : "no";
|
|
402
|
-
}
|
|
403
|
-
function parseFailureModeRescueFlag(rescueCell) {
|
|
404
|
-
const keyed = parseKeyedBinaryFlag(rescueCell, "rescued");
|
|
405
|
-
if (keyed !== "unknown")
|
|
406
|
-
return keyed;
|
|
407
|
-
const direct = parseBinaryFlag(rescueCell);
|
|
408
|
-
if (direct !== "unknown")
|
|
409
|
-
return direct;
|
|
410
|
-
if (/\b(?:no rescue|without rescue|unrescued|no fallback|none|absent)\b/iu.test(rescueCell)) {
|
|
411
|
-
return "no";
|
|
412
|
-
}
|
|
413
|
-
if (/\b(?:fallback|retry|degrade|recover|rescue|mitigat)\b/iu.test(rescueCell)) {
|
|
414
|
-
return "yes";
|
|
415
|
-
}
|
|
416
|
-
return "unknown";
|
|
417
|
-
}
|
|
418
|
-
function parseFailureModeTestFlag(rowText) {
|
|
419
|
-
const keyed = parseKeyedBinaryFlag(rowText, "test");
|
|
420
|
-
if (keyed !== "unknown")
|
|
421
|
-
return keyed;
|
|
422
|
-
if (/\b(?:no tests?|untested|without tests?)\b/iu.test(rowText)) {
|
|
423
|
-
return "no";
|
|
424
|
-
}
|
|
425
|
-
if (/\b(?:tested|has tests?|with tests?|covered by tests?)\b/iu.test(rowText)) {
|
|
426
|
-
return "yes";
|
|
427
|
-
}
|
|
428
|
-
return "unknown";
|
|
429
|
-
}
|
|
430
|
-
function validateFailureModeTable(sectionBody) {
|
|
431
|
-
const header = tableHeaderCells(sectionBody);
|
|
432
|
-
if (!header) {
|
|
433
|
-
return {
|
|
434
|
-
ok: false,
|
|
435
|
-
details: "Failure Mode Table must include a markdown header row and separator."
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
const expectedHeader = ["Method", "Exception", "Rescue", "UserSees"];
|
|
439
|
-
const normalizedHeader = header.map((cell) => cell.toLowerCase());
|
|
440
|
-
const normalizedExpected = expectedHeader.map((cell) => cell.toLowerCase());
|
|
441
|
-
const headerMatches = normalizedHeader.length === normalizedExpected.length &&
|
|
442
|
-
normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
|
|
443
|
-
if (!headerMatches) {
|
|
444
|
-
return {
|
|
445
|
-
ok: false,
|
|
446
|
-
details: `Failure Mode Table header must be exactly: ${expectedHeader.join(" | ")}.`
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
450
|
-
if (rows.length === 0) {
|
|
451
|
-
return {
|
|
452
|
-
ok: false,
|
|
453
|
-
details: "Failure Mode Table must include at least one data row."
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
for (const [index, row] of rows.entries()) {
|
|
457
|
-
if (row.length < 4) {
|
|
458
|
-
return {
|
|
459
|
-
ok: false,
|
|
460
|
-
details: `Failure Mode Table row ${index + 1} must provide 4 columns (Method, Exception, Rescue, UserSees).`
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
const method = (row[0] ?? "").trim();
|
|
464
|
-
const exception = (row[1] ?? "").trim();
|
|
465
|
-
const rescue = (row[2] ?? "").trim();
|
|
466
|
-
const userSees = (row[3] ?? "").trim();
|
|
467
|
-
if (!method || !exception || !rescue || !userSees) {
|
|
468
|
-
return {
|
|
469
|
-
ok: false,
|
|
470
|
-
details: `Failure Mode Table row ${index + 1} must populate all columns (Method, Exception, Rescue, UserSees).`
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
const rescueFlag = parseFailureModeRescueFlag(rescue);
|
|
474
|
-
const testFlag = parseFailureModeTestFlag(`${method} ${exception} ${rescue} ${userSees}`);
|
|
475
|
-
const userSilent = /\bsilent\b/iu.test(userSees);
|
|
476
|
-
if (rescueFlag === "no" && testFlag === "no" && userSilent) {
|
|
477
|
-
return {
|
|
478
|
-
ok: false,
|
|
479
|
-
details: `Failure Mode Table CRITICAL row ${index + 1} (${method}): RESCUED=N + TEST=N + UserSees=Silent. Add rescue path, add test coverage, or make user impact explicit.`
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return {
|
|
484
|
-
ok: true,
|
|
485
|
-
details: "Failure Mode Table header and critical-risk checks passed."
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
// Canonical scope mode tokens (gstack CEO review). The four mode names live in
|
|
489
|
-
// the scope skill, the artifact template, and downstream traces. Requiring one
|
|
490
|
-
// of them in Scope Summary is **structural** — not free-form English keyword
|
|
491
|
-
// matching on user prose. Authors may also use the canonical short form on a
|
|
492
|
-
// `Mode:` / `Selected mode:` line (e.g. `Selected mode: hold`) as a courtesy.
|
|
493
|
-
const SCOPE_MODE_FULL_TOKENS = [
|
|
494
|
-
"SCOPE EXPANSION",
|
|
495
|
-
"SELECTIVE EXPANSION",
|
|
496
|
-
"HOLD SCOPE",
|
|
497
|
-
"SCOPE REDUCTION"
|
|
498
|
-
];
|
|
499
|
-
const SCOPE_MODE_FULL_REGEX = new RegExp("\\b(?:" +
|
|
500
|
-
SCOPE_MODE_FULL_TOKENS
|
|
501
|
-
.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "[\\s_-]+"))
|
|
502
|
-
.join("|") +
|
|
503
|
-
")\\b", "iu");
|
|
504
|
-
// Short-form synonyms accepted only when stamped on an explicit `Mode:` /
|
|
505
|
-
// `Selected mode:` / `Scope mode:` line. Plain prose with the same word does
|
|
506
|
-
// not count, so `strict` / `broad` / `narrow` / similar non-mode adjectives
|
|
507
|
-
// remain rejected.
|
|
508
|
-
const SCOPE_MODE_LINE_REGEX = /(?:^|\n)\s*[-*]?\s*\**\s*(?:Selected\s+|Scope\s+)?Mode\**\s*:\s*\**\s*([^\n]+)/iu;
|
|
509
|
-
const SCOPE_MODE_SHORT_TOKEN_REGEX = /\b(?:hold(?:[\s_-]?scope)?|selective(?:[\s_-]?expansion)?|scope[\s_-]?expansion|expansion|scope[\s_-]?reduction|reduction|expand|reduce)\b/iu;
|
|
510
|
-
// Next-stage handoff token. We only enforce the canonical machine-surface stage
|
|
511
|
-
// IDs (`design`, `spec`) plus stable handoff phrases. The surrounding prose may
|
|
512
|
-
// be written in any language — this guards the downstream cross-stage trace,
|
|
513
|
-
// not the wording of the rationale.
|
|
514
|
-
const NEXT_STAGE_HANDOFF_REGEX = /(?:`(?:design|spec)`|\bdesign\b|\bspec\b|next[-\s_]stage|next stage|handoff|hand[-\s]off)/iu;
|
|
515
|
-
function hasCanonicalScopeMode(body) {
|
|
516
|
-
// Strict: a Mode: / Selected mode: line that picks exactly ONE canonical mode
|
|
517
|
-
// is the strongest signal. The template scaffolding contains all four mode
|
|
518
|
-
// tokens inside an instructional `(one of ...)` placeholder; we ignore that
|
|
519
|
-
// line so authors who never replace the scaffolding still fail validation.
|
|
520
|
-
for (const match of body.matchAll(new RegExp(SCOPE_MODE_LINE_REGEX, "giu"))) {
|
|
521
|
-
const raw = (match[1] ?? "").trim();
|
|
522
|
-
const sanitized = raw.replace(/\(.*?\)/gu, "").trim();
|
|
523
|
-
if (sanitized.length === 0)
|
|
524
|
-
continue;
|
|
525
|
-
if (countCanonicalModeMentions(sanitized) === 1)
|
|
526
|
-
return true;
|
|
527
|
-
if (countCanonicalModeMentions(sanitized) === 0 && SCOPE_MODE_SHORT_TOKEN_REGEX.test(sanitized))
|
|
528
|
-
return true;
|
|
529
|
-
}
|
|
530
|
-
// Fallback: any line outside an instructional `(one of ...)` placeholder
|
|
531
|
-
// names exactly one mode. Block lines that list multiple modes (the
|
|
532
|
-
// unfilled template) or are wrapped in an instructional parenthetical.
|
|
533
|
-
for (const rawLine of body.split(/\r?\n/u)) {
|
|
534
|
-
const line = rawLine.trim();
|
|
535
|
-
if (line.length === 0)
|
|
536
|
-
continue;
|
|
537
|
-
if (/\(\s*one\s+of\b/iu.test(line))
|
|
538
|
-
continue;
|
|
539
|
-
const sanitized = line.replace(/\(.*?\)/gu, "");
|
|
540
|
-
if (countCanonicalModeMentions(sanitized) === 1)
|
|
541
|
-
return true;
|
|
542
|
-
}
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
function countCanonicalModeMentions(text) {
|
|
546
|
-
const matches = text.match(new RegExp(SCOPE_MODE_FULL_REGEX, "giu"));
|
|
547
|
-
return matches ? matches.length : 0;
|
|
548
|
-
}
|
|
549
|
-
function validatePremiseChallenge(sectionBody) {
|
|
550
|
-
// gstack-style premise challenge requires a real Q/A structure (table or
|
|
551
|
-
// list), not free-form prose. The validation is *structural* only — we do
|
|
552
|
-
// NOT keyword-grep for English phrases like "right problem"; authors may
|
|
553
|
-
// write the questions in any language, and the answers carry the meaning.
|
|
554
|
-
// The template ships with canonical question labels as scaffolding, but
|
|
555
|
-
// the linter only enforces that the section actually compares premise
|
|
556
|
-
// questions to answers.
|
|
557
|
-
const tableRows = getMarkdownTableRows(sectionBody);
|
|
558
|
-
const bulletRows = sectionBody
|
|
559
|
-
.split(/\r?\n/u)
|
|
560
|
-
.map((line) => line.trim())
|
|
561
|
-
.filter((line) => /^(?:[-*]|\d+\.)\s+\S/u.test(line));
|
|
562
|
-
const rowCount = Math.max(tableRows.length, bulletRows.length);
|
|
563
|
-
if (rowCount < 3) {
|
|
564
|
-
return {
|
|
565
|
-
ok: false,
|
|
566
|
-
details: `Premise Challenge needs at least 3 substantive rows in a table or bullet list. Found ${rowCount}.`
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
// For tables, each data row must have at least 2 non-empty cells so the
|
|
570
|
-
// section is genuinely a premise/answer comparison, not a list of headlines.
|
|
571
|
-
// For bullet lists, each line must be substantive so we don't accept
|
|
572
|
-
// placeholders like `- a`; punctuation style and natural language do not
|
|
573
|
-
// matter.
|
|
574
|
-
if (tableRows.length >= 3) {
|
|
575
|
-
const sparseRows = tableRows.filter((row) => {
|
|
576
|
-
const filledCells = row.filter((cell) => cell.replace(/[\s|]/gu, "").length >= 2);
|
|
577
|
-
return filledCells.length < 2;
|
|
578
|
-
});
|
|
579
|
-
if (sparseRows.length > 0) {
|
|
580
|
-
return {
|
|
581
|
-
ok: false,
|
|
582
|
-
details: "Premise Challenge table rows must populate at least the question and answer columns (no empty answers)."
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
else if (bulletRows.length >= 3) {
|
|
587
|
-
const sparseBullets = bulletRows.filter((line) => {
|
|
588
|
-
const cleaned = line.replace(/^[-*\d.\s]+/u, "").replace(/[`*_]/gu, "").trim();
|
|
589
|
-
const meaningful = cleaned.match(/[\p{L}\p{N}]/gu)?.length ?? 0;
|
|
590
|
-
return meaningful < 12;
|
|
591
|
-
});
|
|
592
|
-
if (sparseBullets.length > 0) {
|
|
593
|
-
return {
|
|
594
|
-
ok: false,
|
|
595
|
-
details: "Premise Challenge bullet list must include at least 3 substantive rows, not placeholders."
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
return {
|
|
600
|
-
ok: true,
|
|
601
|
-
details: `Premise Challenge structures ${rowCount} Q/A rows.`
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
function validateScopeSummary(sectionBody) {
|
|
605
|
-
const meaningfulLines = sectionBody
|
|
606
|
-
.split(/\r?\n/)
|
|
607
|
-
.map((line) => line.trim())
|
|
608
|
-
.filter((line) => line.length > 0 && /[\p{L}\p{N}]/u.test(line));
|
|
609
|
-
if (meaningfulLines.length < 2) {
|
|
610
|
-
return {
|
|
611
|
-
ok: false,
|
|
612
|
-
details: "Scope Summary must list at least 2 substantive lines covering the selected mode and the next-stage handoff."
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
if (!hasCanonicalScopeMode(sectionBody)) {
|
|
616
|
-
return {
|
|
617
|
-
ok: false,
|
|
618
|
-
details: "Scope Summary must name the selected mode using a canonical token (SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, SCOPE REDUCTION) or a short form on a `Mode:` line (hold, selective, expansion, reduction)."
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
if (!NEXT_STAGE_HANDOFF_REGEX.test(sectionBody)) {
|
|
622
|
-
return {
|
|
623
|
-
ok: false,
|
|
624
|
-
details: "Scope Summary must record the track-aware next-stage handoff (mention `design` for standard, `spec` for medium, or include a `Next-stage handoff:` line)."
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
return {
|
|
628
|
-
ok: true,
|
|
629
|
-
details: "Scope Summary names the selected mode and the next-stage handoff."
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
const APPROACH_ROLE_VALUES = ["baseline", "challenger", "wild-card"];
|
|
633
|
-
const APPROACH_UPSIDE_VALUES = ["low", "modest", "high", "higher"];
|
|
634
|
-
const REQUIREMENT_PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "DROPPED"];
|
|
635
|
-
function normalizeTableToken(value) {
|
|
636
|
-
return value
|
|
637
|
-
.replace(/[`*_]/gu, "")
|
|
638
|
-
.trim()
|
|
639
|
-
.toLowerCase()
|
|
640
|
-
.replace(/\s+/gu, "-");
|
|
641
|
-
}
|
|
642
|
-
function columnIndex(header, expected) {
|
|
643
|
-
return header.findIndex((cell) => normalizeTableToken(cell) === expected);
|
|
644
|
-
}
|
|
645
|
-
function validateApproachesTaxonomy(sectionBody) {
|
|
646
|
-
const header = tableHeaderCells(sectionBody);
|
|
647
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
648
|
-
if (!header) {
|
|
649
|
-
return {
|
|
650
|
-
rowCount: 0,
|
|
651
|
-
roleUpsideOk: false,
|
|
652
|
-
challengerOk: false,
|
|
653
|
-
details: "Approaches must be a markdown table with canonical Role and Upside columns."
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
const roleIndex = columnIndex(header, "role");
|
|
657
|
-
const upsideIndex = columnIndex(header, "upside");
|
|
658
|
-
if (roleIndex < 0 || upsideIndex < 0) {
|
|
659
|
-
const firstColumnTokens = rows.map((row) => normalizeTableToken(row[0] ?? ""));
|
|
660
|
-
const appearsTransposed = firstColumnTokens.includes("role") || firstColumnTokens.includes("upside");
|
|
661
|
-
return {
|
|
662
|
-
rowCount: rows.length,
|
|
663
|
-
roleUpsideOk: false,
|
|
664
|
-
challengerOk: false,
|
|
665
|
-
details: appearsTransposed
|
|
666
|
-
? "Approaches table appears transposed: `Role`/`Upside` are rows, but must be columns. Use `| Approach | Role | Upside | ... |` with one approach per row."
|
|
667
|
-
: "Approaches table must include canonical `Role` and `Upside` columns (Role: baseline | challenger | wild-card; Upside: low | modest | high | higher)."
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
let challengerRows = 0;
|
|
671
|
-
let challengerHasHighUpside = false;
|
|
672
|
-
for (const [index, row] of rows.entries()) {
|
|
673
|
-
const role = normalizeTableToken(row[roleIndex] ?? "");
|
|
674
|
-
const upside = normalizeTableToken(row[upsideIndex] ?? "");
|
|
675
|
-
if (!APPROACH_ROLE_VALUES.includes(role)) {
|
|
676
|
-
return {
|
|
677
|
-
rowCount: rows.length,
|
|
678
|
-
roleUpsideOk: false,
|
|
679
|
-
challengerOk: false,
|
|
680
|
-
details: `Approaches row ${index + 1} has invalid Role "${row[roleIndex] ?? ""}". Expected one of: ${APPROACH_ROLE_VALUES.join(", ")}.`
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
if (!APPROACH_UPSIDE_VALUES.includes(upside)) {
|
|
684
|
-
return {
|
|
685
|
-
rowCount: rows.length,
|
|
686
|
-
roleUpsideOk: false,
|
|
687
|
-
challengerOk: false,
|
|
688
|
-
details: `Approaches row ${index + 1} has invalid Upside "${row[upsideIndex] ?? ""}". Expected one of: ${APPROACH_UPSIDE_VALUES.join(", ")}.`
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
if (role === "challenger") {
|
|
692
|
-
challengerRows += 1;
|
|
693
|
-
if (upside === "high" || upside === "higher") {
|
|
694
|
-
challengerHasHighUpside = true;
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
const challengerOk = challengerRows === 1 && challengerHasHighUpside;
|
|
699
|
-
return {
|
|
700
|
-
rowCount: rows.length,
|
|
701
|
-
roleUpsideOk: true,
|
|
702
|
-
challengerOk,
|
|
703
|
-
details: challengerOk
|
|
704
|
-
? "Approaches table uses canonical Role/Upside values and exactly one high/higher-upside challenger."
|
|
705
|
-
: `Approaches table must include exactly one challenger row with Upside high or higher. Found ${challengerRows} challenger row(s).`
|
|
706
|
-
};
|
|
707
|
-
}
|
|
708
|
-
function validateCalibratedSelfReview(sectionBody) {
|
|
709
|
-
const statusLineMatch = /^\s*-\s*Status:\s*(.*)$/imu.exec(sectionBody);
|
|
710
|
-
const statusValue = statusLineMatch ? statusLineMatch[1].trim() : "";
|
|
711
|
-
const mentionsApproved = /\bApproved\b/iu.test(statusValue);
|
|
712
|
-
const mentionsIssuesFound = /\bIssues Found\b/iu.test(statusValue);
|
|
713
|
-
const statusPickedExactlyOne = statusLineMatch !== null && (mentionsApproved !== mentionsIssuesFound);
|
|
714
|
-
const hasPatchesHeader = /^\s*-\s*Patches applied:/imu.test(sectionBody);
|
|
715
|
-
const hasConcernsHeader = /^\s*-\s*Remaining concerns:/imu.test(sectionBody);
|
|
716
|
-
if (statusPickedExactlyOne && hasPatchesHeader && hasConcernsHeader) {
|
|
717
|
-
return {
|
|
718
|
-
ok: true,
|
|
719
|
-
details: "Self-Review Notes use the calibrated review prompt format."
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
const problems = [];
|
|
723
|
-
if (!statusLineMatch) {
|
|
724
|
-
problems.push("missing `- Status:` line");
|
|
725
|
-
}
|
|
726
|
-
else if (!mentionsApproved && !mentionsIssuesFound) {
|
|
727
|
-
problems.push("`- Status:` must include `Approved` or `Issues Found`");
|
|
728
|
-
}
|
|
729
|
-
else if (mentionsApproved && mentionsIssuesFound) {
|
|
730
|
-
problems.push("`- Status:` must pick exactly one of `Approved` or `Issues Found` (the placeholder `Approved | Issues Found` is not a decision)");
|
|
731
|
-
}
|
|
732
|
-
if (!hasPatchesHeader)
|
|
733
|
-
problems.push("missing `- Patches applied:` line");
|
|
734
|
-
if (!hasConcernsHeader)
|
|
735
|
-
problems.push("missing `- Remaining concerns:` line");
|
|
736
|
-
return {
|
|
737
|
-
ok: false,
|
|
738
|
-
details: "Self-Review Notes must use the calibrated review prompt format: `- Status: Approved` (or `Issues Found`), `- Patches applied:` (inline note or sub-bullets), and `- Remaining concerns:` (inline note or sub-bullets). Issues: " +
|
|
739
|
-
problems.join("; ") +
|
|
740
|
-
"."
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
function validateRequirementsTaxonomy(sectionBody) {
|
|
744
|
-
const header = tableHeaderCells(sectionBody);
|
|
745
|
-
if (!header) {
|
|
746
|
-
return {
|
|
747
|
-
ok: false,
|
|
748
|
-
details: "Requirements must be a markdown table with a Priority column."
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
const priorityIndex = columnIndex(header, "priority");
|
|
752
|
-
if (priorityIndex < 0) {
|
|
753
|
-
return {
|
|
754
|
-
ok: false,
|
|
755
|
-
details: "Requirements table must include a canonical `Priority` column."
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
759
|
-
if (rows.length === 0) {
|
|
760
|
-
return {
|
|
761
|
-
ok: false,
|
|
762
|
-
details: "Requirements table must include at least one requirement row."
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
for (const [index, row] of rows.entries()) {
|
|
766
|
-
const rawPriority = (row[priorityIndex] ?? "").replace(/[`*_]/gu, "").trim().toUpperCase();
|
|
767
|
-
if (!REQUIREMENT_PRIORITY_VALUES.includes(rawPriority)) {
|
|
768
|
-
return {
|
|
769
|
-
ok: false,
|
|
770
|
-
details: `Requirements row ${index + 1} has invalid Priority "${row[priorityIndex] ?? ""}". Expected one of: ${REQUIREMENT_PRIORITY_VALUES.join(", ")}.`
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
return {
|
|
775
|
-
ok: true,
|
|
776
|
-
details: "Requirements table uses canonical Priority values."
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
function validateLockedDecisionAnchors(sectionBody) {
|
|
780
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
781
|
-
const lines = sectionBody
|
|
782
|
-
.split(/\r?\n/u)
|
|
783
|
-
.map((line) => line.trim())
|
|
784
|
-
.filter((line) => /^[-*]\s+\S/u.test(line));
|
|
785
|
-
const anchors = [];
|
|
786
|
-
const issues = [];
|
|
787
|
-
for (const [index, row] of rows.entries()) {
|
|
788
|
-
const anchor = (row[0] ?? "").trim().toLowerCase();
|
|
789
|
-
const decisionText = (row[1] ?? "").trim();
|
|
790
|
-
if (!/^ld#[0-9a-f]{8}$/u.test(anchor)) {
|
|
791
|
-
issues.push(`row ${index + 1} has invalid anchor "${row[0] ?? ""}"`);
|
|
792
|
-
continue;
|
|
793
|
-
}
|
|
794
|
-
anchors.push(anchor);
|
|
795
|
-
if (decisionText.length > 0) {
|
|
796
|
-
const expected = lockedDecisionHash(decisionText).toLowerCase();
|
|
797
|
-
if (anchor !== expected) {
|
|
798
|
-
issues.push(`row ${index + 1} anchor should be ${expected} for its Decision text`);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
for (const [index, line] of lines.entries()) {
|
|
803
|
-
const anchor = /\bLD#[0-9a-f]{8}\b/iu.exec(line)?.[0]?.toLowerCase();
|
|
804
|
-
if (!anchor) {
|
|
805
|
-
issues.push(`bullet ${index + 1} is missing an LD#<sha8> anchor`);
|
|
806
|
-
continue;
|
|
807
|
-
}
|
|
808
|
-
anchors.push(anchor);
|
|
809
|
-
}
|
|
810
|
-
const duplicateAnchors = [...new Set(anchors.filter((anchor, index) => anchors.indexOf(anchor) !== index))];
|
|
811
|
-
if (duplicateAnchors.length > 0) {
|
|
812
|
-
issues.push(`duplicate anchors: ${duplicateAnchors.join(", ")}`);
|
|
813
|
-
}
|
|
814
|
-
if (anchors.length === 0 && (rows.length > 0 || lines.length > 0)) {
|
|
815
|
-
issues.push("no LD#<sha8> anchors found");
|
|
816
|
-
}
|
|
817
|
-
return {
|
|
818
|
-
ok: issues.length === 0,
|
|
819
|
-
anchors: [...new Set(anchors)],
|
|
820
|
-
details: issues.length === 0
|
|
821
|
-
? `${anchors.length} LD#hash anchor(s) recorded with no duplicates.`
|
|
822
|
-
: issues.join("; ")
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
const INTERACTION_EDGE_CASE_REQUIREMENTS = [
|
|
826
|
-
{ label: "double-click", pattern: /\bdouble[\s-]?click\b/iu },
|
|
827
|
-
{
|
|
828
|
-
label: "nav-away-mid-request",
|
|
829
|
-
pattern: /\b(?:nav(?:igate)?[\s-]?away(?:[\s-]?mid[\s-]?request)?|leave\s+(?:page|view|screen).*(?:request|save|submit)|close\s+tab.*(?:request|save|submit))\b/iu
|
|
830
|
-
},
|
|
831
|
-
{
|
|
832
|
-
label: "10K-result dataset",
|
|
833
|
-
pattern: /\b(?:10k(?:[\s-]?result)?|10,?000|large[\s-]?result(?:[\s-]?dataset)?)\b/iu
|
|
834
|
-
},
|
|
835
|
-
{
|
|
836
|
-
label: "background-job abandonment",
|
|
837
|
-
pattern: /\b(?:background[\s-]?job.*abandon(?:ed|ment)?|abandon(?:ed|ment)?.*background[\s-]?job)\b/iu
|
|
838
|
-
},
|
|
839
|
-
{ label: "zombie connection", pattern: /\bzombie[\s-]?connection\b/iu }
|
|
840
|
-
];
|
|
841
|
-
function validateInteractionEdgeCaseMatrix(sectionBody) {
|
|
842
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
843
|
-
if (rows.length === 0) {
|
|
844
|
-
return {
|
|
845
|
-
ok: false,
|
|
846
|
-
details: "Data Flow must include an Interaction Edge Case matrix table with required rows."
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
const seen = new Map();
|
|
850
|
-
for (const [index, row] of rows.entries()) {
|
|
851
|
-
const labelCell = (row[0] ?? "").trim();
|
|
852
|
-
if (!labelCell)
|
|
853
|
-
continue;
|
|
854
|
-
const requirement = INTERACTION_EDGE_CASE_REQUIREMENTS.find((candidate) => candidate.pattern.test(labelCell));
|
|
855
|
-
if (!requirement)
|
|
856
|
-
continue;
|
|
857
|
-
if (row.length < 4) {
|
|
858
|
-
return {
|
|
859
|
-
ok: false,
|
|
860
|
-
details: `Interaction Edge Case row "${requirement.label}" must include 4 columns: Edge case | Handled? | Design response | Deferred item.`
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
const handled = parseBinaryFlag((row[1] ?? "").trim());
|
|
864
|
-
const response = (row[2] ?? "").trim();
|
|
865
|
-
const deferred = (row[3] ?? "").trim();
|
|
866
|
-
if (handled === "unknown") {
|
|
867
|
-
return {
|
|
868
|
-
ok: false,
|
|
869
|
-
details: `Interaction Edge Case row "${requirement.label}" must mark Handled? as yes/no.`
|
|
870
|
-
};
|
|
871
|
-
}
|
|
872
|
-
if (!response) {
|
|
873
|
-
return {
|
|
874
|
-
ok: false,
|
|
875
|
-
details: `Interaction Edge Case row "${requirement.label}" must describe the design response.`
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
if (handled === "no" && (!deferred || /\bnone\b/iu.test(deferred))) {
|
|
879
|
-
return {
|
|
880
|
-
ok: false,
|
|
881
|
-
details: `Interaction Edge Case row "${requirement.label}" is unhandled and must reference a deferred item id (for example D-12).`
|
|
882
|
-
};
|
|
883
|
-
}
|
|
884
|
-
seen.set(requirement.label, true);
|
|
885
|
-
}
|
|
886
|
-
const missing = INTERACTION_EDGE_CASE_REQUIREMENTS
|
|
887
|
-
.map((requirement) => requirement.label)
|
|
888
|
-
.filter((label) => !seen.has(label));
|
|
889
|
-
if (missing.length > 0) {
|
|
890
|
-
return {
|
|
891
|
-
ok: false,
|
|
892
|
-
details: `Interaction Edge Case matrix is missing required row(s): ${missing.join(", ")}.`
|
|
893
|
-
};
|
|
894
|
-
}
|
|
895
|
-
return {
|
|
896
|
-
ok: true,
|
|
897
|
-
details: "Interaction Edge Case matrix contains all required rows with handled/deferred status."
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
const PRE_SCOPE_AUDIT_SIGNALS = [
|
|
901
|
-
{ label: "git log -30 --oneline", pattern: /\bgit\s+log\b[^\n]*-30[^\n]*\boneline\b/iu },
|
|
902
|
-
{ label: "git diff --stat", pattern: /\bgit\s+diff\b[^\n]*--stat\b/iu },
|
|
903
|
-
{ label: "git stash list", pattern: /\bgit\s+stash\s+list\b/iu },
|
|
904
|
-
{
|
|
905
|
-
label: "debt marker scan (TODO|FIXME|XXX|HACK)",
|
|
906
|
-
pattern: /\b(?:rg|ripgrep)\b[^\n]*(?:TODO|FIXME|XXX|HACK)|\bTODO\b|\bFIXME\b|\bXXX\b|\bHACK\b/iu
|
|
907
|
-
}
|
|
908
|
-
];
|
|
909
|
-
function validatePreScopeSystemAudit(sectionBody) {
|
|
910
|
-
const missing = PRE_SCOPE_AUDIT_SIGNALS
|
|
911
|
-
.filter((signal) => !signal.pattern.test(sectionBody))
|
|
912
|
-
.map((signal) => signal.label);
|
|
913
|
-
if (missing.length > 0) {
|
|
914
|
-
return {
|
|
915
|
-
ok: false,
|
|
916
|
-
details: `Pre-Scope System Audit is missing required signal(s): ${missing.join(", ")}.`
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
return {
|
|
920
|
-
ok: true,
|
|
921
|
-
details: "Pre-Scope System Audit captures git log/diff/stash/debt-marker checks."
|
|
922
|
-
};
|
|
923
|
-
}
|
|
924
|
-
function normalizeCodebaseInvestigationFileRef(value) {
|
|
925
|
-
const cleaned = value
|
|
926
|
-
.replace(/`/gu, "")
|
|
927
|
-
.replace(/^\s*[-*]\s*/u, "")
|
|
928
|
-
.trim();
|
|
929
|
-
if (!cleaned)
|
|
930
|
-
return null;
|
|
931
|
-
if (/^(?:file|n\/a|none|\(none\)|tbd|\?)$/iu.test(cleaned))
|
|
932
|
-
return null;
|
|
933
|
-
return cleaned;
|
|
934
|
-
}
|
|
935
|
-
function collectCodebaseInvestigationFiles(sectionBody) {
|
|
936
|
-
const refs = [];
|
|
937
|
-
for (const row of getMarkdownTableRows(sectionBody)) {
|
|
938
|
-
const fileCell = normalizeCodebaseInvestigationFileRef(row[0] ?? "");
|
|
939
|
-
if (fileCell)
|
|
940
|
-
refs.push(fileCell);
|
|
941
|
-
}
|
|
942
|
-
return [...new Set(refs)];
|
|
943
|
-
}
|
|
944
|
-
async function runStaleDiagramAudit(projectRoot, artifactPath, artifactRaw, codebaseInvestigationBody) {
|
|
945
|
-
const markerCount = (artifactRaw.match(/<!--\s*diagram:\s*[a-z0-9-]+\s*-->/giu) ?? []).length;
|
|
946
|
-
if (markerCount === 0) {
|
|
947
|
-
return {
|
|
948
|
-
ok: false,
|
|
949
|
-
details: "No diagram markers found in design artifact; stale-diagram baseline cannot be computed."
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
let artifactStat;
|
|
953
|
-
try {
|
|
954
|
-
artifactStat = await fs.stat(artifactPath);
|
|
955
|
-
}
|
|
956
|
-
catch {
|
|
957
|
-
return {
|
|
958
|
-
ok: false,
|
|
959
|
-
details: "Cannot stat design artifact to compute diagram marker baseline."
|
|
960
|
-
};
|
|
961
|
-
}
|
|
962
|
-
const refs = collectCodebaseInvestigationFiles(codebaseInvestigationBody);
|
|
963
|
-
if (refs.length === 0) {
|
|
964
|
-
return {
|
|
965
|
-
ok: false,
|
|
966
|
-
details: "Codebase Investigation must list at least one blast-radius file for stale-diagram audit."
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
const stale = [];
|
|
970
|
-
const missing = [];
|
|
971
|
-
let scanned = 0;
|
|
972
|
-
for (const ref of refs) {
|
|
973
|
-
const absPath = path.isAbsolute(ref) ? ref : path.join(projectRoot, ref);
|
|
974
|
-
if (!(await exists(absPath))) {
|
|
975
|
-
missing.push(ref);
|
|
976
|
-
continue;
|
|
977
|
-
}
|
|
978
|
-
let fileStat;
|
|
979
|
-
try {
|
|
980
|
-
fileStat = await fs.stat(absPath);
|
|
981
|
-
}
|
|
982
|
-
catch {
|
|
983
|
-
missing.push(ref);
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
if (!fileStat.isFile())
|
|
987
|
-
continue;
|
|
988
|
-
scanned += 1;
|
|
989
|
-
if (fileStat.mtimeMs > artifactStat.mtimeMs) {
|
|
990
|
-
stale.push(ref);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
if (missing.length > 0) {
|
|
994
|
-
return {
|
|
995
|
-
ok: false,
|
|
996
|
-
details: `Stale Diagram Audit could not read blast-radius file(s): ${missing.join(", ")}.`
|
|
997
|
-
};
|
|
998
|
-
}
|
|
999
|
-
if (scanned === 0) {
|
|
1000
|
-
return {
|
|
1001
|
-
ok: false,
|
|
1002
|
-
details: "Stale Diagram Audit found no readable blast-radius files in Codebase Investigation."
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
if (stale.length > 0) {
|
|
1006
|
-
return {
|
|
1007
|
-
ok: false,
|
|
1008
|
-
details: `Stale Diagram Audit flagged stale file(s) newer than diagram baseline: ${stale.join(", ")}.`
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
return {
|
|
1012
|
-
ok: true,
|
|
1013
|
-
details: `Stale Diagram Audit clear: ${scanned} blast-radius file(s) are not newer than diagram baseline.`
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦)/u;
|
|
1017
|
-
const DIAGRAM_FAILURE_EDGE_PATTERN = /\b(fail(?:ed|ure)?|error|timeout|fallback|degrad(?:e|ed|ation)|retry|backoff|circuit|unavailable|recover(?:y)?|rescue|mitigat(?:e|ion)|rollback|exception|abort|dead[\s-]?letter|dlq)\b/iu;
|
|
1018
|
-
const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
|
|
1019
|
-
const TEST_COMMAND_MARKER_PATTERN = /\b(?:npm|pnpm|yarn|bun|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)\b/iu;
|
|
1020
|
-
const RED_FAILURE_MARKER_PATTERN = /\b(?:fail|failed|failing|assertionerror|cannot find|exception|error|exit code\s*[:=]?\s*[1-9])\b/iu;
|
|
1021
|
-
const GREEN_SUCCESS_MARKER_PATTERN = /\b(?:pass|passed|green|ok|0 failed|exit code\s*[:=]?\s*0)\b/iu;
|
|
1022
|
-
function diagramEdgeLines(sectionBody) {
|
|
1023
|
-
return sectionBody
|
|
1024
|
-
.split(/\r?\n/)
|
|
1025
|
-
.map((line) => line.trim())
|
|
1026
|
-
.filter((line) => line.length > 0)
|
|
1027
|
-
.filter((line) => !line.startsWith("```"))
|
|
1028
|
-
.filter((line) => !line.startsWith("%%"))
|
|
1029
|
-
.filter((line) => DIAGRAM_ARROW_PATTERN.test(line));
|
|
1030
|
-
}
|
|
1031
|
-
function hasFailureEdgeInDiagram(sectionBody) {
|
|
1032
|
-
const lines = diagramEdgeLines(sectionBody);
|
|
1033
|
-
for (const line of lines) {
|
|
1034
|
-
if (DIAGRAM_ARROW_PATTERN.test(line) && DIAGRAM_FAILURE_EDGE_PATTERN.test(line)) {
|
|
1035
|
-
return true;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
return false;
|
|
1039
|
-
}
|
|
1040
|
-
function hasLabeledDiagramArrow(lines) {
|
|
1041
|
-
return lines.some((line) => /\|[^|]+\|/u.test(line) || /:\s*[A-Za-z]/u.test(line));
|
|
1042
|
-
}
|
|
1043
|
-
function hasAsyncDiagramEdge(lines) {
|
|
1044
|
-
return lines.some((line) => /-\.->|-->>|~~>|\basync\b/iu.test(line));
|
|
1045
|
-
}
|
|
1046
|
-
function hasSyncDiagramEdge(lines) {
|
|
1047
|
-
return lines.some((line) => {
|
|
1048
|
-
if (/\bsync\b/iu.test(line))
|
|
1049
|
-
return true;
|
|
1050
|
-
if (!/(-->|->|=>|→|⟶|↦)/u.test(line))
|
|
1051
|
-
return false;
|
|
1052
|
-
return !/-\.->|-->>|~~>/u.test(line);
|
|
1053
|
-
});
|
|
1054
|
-
}
|
|
1055
|
-
function validateTddRedEvidence(sectionBody) {
|
|
1056
|
-
const meaningful = meaningfulLineCount(sectionBody);
|
|
1057
|
-
if (meaningful < 2) {
|
|
1058
|
-
return {
|
|
1059
|
-
ok: false,
|
|
1060
|
-
details: "RED Evidence must include at least 2 meaningful lines (command plus failing output context)."
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
|
|
1064
|
-
return {
|
|
1065
|
-
ok: false,
|
|
1066
|
-
details: "RED Evidence must include the test command that produced the failure."
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
if (!RED_FAILURE_MARKER_PATTERN.test(sectionBody)) {
|
|
1070
|
-
return {
|
|
1071
|
-
ok: false,
|
|
1072
|
-
details: "RED Evidence must include explicit failing output markers (FAIL/FAILED/AssertionError/exit code != 0)."
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
return {
|
|
1076
|
-
ok: true,
|
|
1077
|
-
details: "RED Evidence includes command + failing output markers."
|
|
1078
|
-
};
|
|
1079
|
-
}
|
|
1080
|
-
function validateTddGreenEvidence(sectionBody) {
|
|
1081
|
-
const meaningful = meaningfulLineCount(sectionBody);
|
|
1082
|
-
if (meaningful < 2) {
|
|
1083
|
-
return {
|
|
1084
|
-
ok: false,
|
|
1085
|
-
details: "GREEN Evidence must include at least 2 meaningful lines (command and passing result)."
|
|
1086
|
-
};
|
|
1087
|
-
}
|
|
1088
|
-
if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
|
|
1089
|
-
return {
|
|
1090
|
-
ok: false,
|
|
1091
|
-
details: "GREEN Evidence must include the full-suite test command."
|
|
1092
|
-
};
|
|
1093
|
-
}
|
|
1094
|
-
if (!GREEN_SUCCESS_MARKER_PATTERN.test(sectionBody)) {
|
|
1095
|
-
return {
|
|
1096
|
-
ok: false,
|
|
1097
|
-
details: "GREEN Evidence must include explicit passing markers (PASS/PASSED/OK/exit code 0)."
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1100
|
-
return {
|
|
1101
|
-
ok: true,
|
|
1102
|
-
details: "GREEN Evidence includes command + passing output markers."
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
function validateVerificationLadder(sectionBody) {
|
|
1106
|
-
const hasTextLine = /highest tier reached/iu.test(sectionBody);
|
|
1107
|
-
const hasCanonicalTable = hasVerificationLadderTableRow(sectionBody);
|
|
1108
|
-
if (!hasTextLine && !hasCanonicalTable) {
|
|
1109
|
-
return {
|
|
1110
|
-
ok: false,
|
|
1111
|
-
details: "Verification Ladder must include either a 'Highest tier reached' line or a canonical table row (Slice | Tier reached | Evidence) with non-empty tier and evidence."
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
if (!/\b(static|command|behavioral|human)\b/iu.test(sectionBody)) {
|
|
1115
|
-
return {
|
|
1116
|
-
ok: false,
|
|
1117
|
-
details: "Verification Ladder must name a tier (static | command | behavioral | human)."
|
|
1118
|
-
};
|
|
1119
|
-
}
|
|
1120
|
-
if (!/\b(evidence|command|sha|commit)\b/iu.test(sectionBody)) {
|
|
1121
|
-
return {
|
|
1122
|
-
ok: false,
|
|
1123
|
-
details: "Verification Ladder must include evidence details (command output or commit SHA)."
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
return {
|
|
1127
|
-
ok: true,
|
|
1128
|
-
details: "Verification Ladder includes tier + evidence fields."
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
function hasVerificationLadderTableRow(sectionBody) {
|
|
1132
|
-
const lines = sectionBody.split(/\r?\n/u);
|
|
1133
|
-
let sawHeader = false;
|
|
1134
|
-
let sawSeparator = false;
|
|
1135
|
-
for (const line of lines) {
|
|
1136
|
-
const trimmed = line.trim();
|
|
1137
|
-
if (!trimmed.startsWith("|")) {
|
|
1138
|
-
sawHeader = false;
|
|
1139
|
-
sawSeparator = false;
|
|
1140
|
-
continue;
|
|
1141
|
-
}
|
|
1142
|
-
const cells = trimmed
|
|
1143
|
-
.replace(/^\|/u, "")
|
|
1144
|
-
.replace(/\|$/u, "")
|
|
1145
|
-
.split("|")
|
|
1146
|
-
.map((cell) => cell.trim());
|
|
1147
|
-
if (!sawHeader) {
|
|
1148
|
-
const lowered = cells.map((cell) => cell.toLowerCase());
|
|
1149
|
-
const hasTierColumn = lowered.some((cell) => /tier(?:\s+reached)?/u.test(cell));
|
|
1150
|
-
const hasEvidenceColumn = lowered.some((cell) => cell.includes("evidence"));
|
|
1151
|
-
if (hasTierColumn && hasEvidenceColumn) {
|
|
1152
|
-
sawHeader = true;
|
|
1153
|
-
continue;
|
|
1154
|
-
}
|
|
1155
|
-
continue;
|
|
1156
|
-
}
|
|
1157
|
-
if (!sawSeparator) {
|
|
1158
|
-
if (cells.every((cell) => /^[:\-\s]+$/u.test(cell))) {
|
|
1159
|
-
sawSeparator = true;
|
|
1160
|
-
continue;
|
|
1161
|
-
}
|
|
1162
|
-
sawHeader = false;
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
if (cells.length >= 2 && cells.some((cell) => /\b(static|command|behavioral|human)\b/iu.test(cell))) {
|
|
1166
|
-
const evidenceCellHasContent = cells.some((cell) => cell.length > 0 && !/^\s*$/u.test(cell) && !/^[:\-\s]+$/u.test(cell));
|
|
1167
|
-
if (evidenceCellHasContent) {
|
|
1168
|
-
return true;
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
return false;
|
|
1173
|
-
}
|
|
1174
|
-
const LEARNING_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
|
|
1175
|
-
const LEARNING_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
|
|
1176
|
-
const LEARNING_SEVERITY_SET = new Set(["critical", "important", "suggestion"]);
|
|
1177
|
-
const LEARNING_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
|
|
1178
|
-
const LEARNING_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
|
|
1179
|
-
const LEARNING_SOURCE_SET = new Set([
|
|
1180
|
-
"stage",
|
|
1181
|
-
"retro",
|
|
1182
|
-
"compound",
|
|
1183
|
-
"ideate",
|
|
1184
|
-
"manual"
|
|
1185
|
-
]);
|
|
1186
|
-
const FLOW_STAGE_SET = new Set(FLOW_STAGES);
|
|
1187
|
-
const LEARNING_ALLOWED_KEYS = new Set([
|
|
1188
|
-
"type",
|
|
1189
|
-
"trigger",
|
|
1190
|
-
"action",
|
|
1191
|
-
"confidence",
|
|
1192
|
-
"severity",
|
|
1193
|
-
"domain",
|
|
1194
|
-
"stage",
|
|
1195
|
-
"origin_stage",
|
|
1196
|
-
"origin_run",
|
|
1197
|
-
"origin_feature",
|
|
1198
|
-
"frequency",
|
|
1199
|
-
"universality",
|
|
1200
|
-
"maturity",
|
|
1201
|
-
"created",
|
|
1202
|
-
"first_seen_ts",
|
|
1203
|
-
"last_seen_ts",
|
|
1204
|
-
"project",
|
|
1205
|
-
"source",
|
|
1206
|
-
"supersedes",
|
|
1207
|
-
"superseded_by"
|
|
1208
|
-
]);
|
|
1209
|
-
function isIsoUtcTimestamp(value) {
|
|
1210
|
-
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value);
|
|
1211
|
-
}
|
|
1212
|
-
function isNullableString(value) {
|
|
1213
|
-
return value === null || typeof value === "string";
|
|
1214
|
-
}
|
|
1215
|
-
function isNullableStage(value) {
|
|
1216
|
-
return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
|
|
1217
|
-
}
|
|
1218
|
-
function parseLearningSeedEntry(raw, index) {
|
|
1219
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1220
|
-
return { ok: false, error: `Learnings bullet #${index} must be a JSON object.` };
|
|
1221
|
-
}
|
|
1222
|
-
const obj = raw;
|
|
1223
|
-
for (const key of Object.keys(obj)) {
|
|
1224
|
-
if (!LEARNING_ALLOWED_KEYS.has(key)) {
|
|
1225
|
-
return {
|
|
1226
|
-
ok: false,
|
|
1227
|
-
error: `Learnings bullet #${index} includes unknown key "${key}" (allowed keys mirror knowledge JSONL fields).`
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
|
|
1232
|
-
if (!LEARNING_TYPE_SET.has(type)) {
|
|
1233
|
-
return {
|
|
1234
|
-
ok: false,
|
|
1235
|
-
error: `Learnings bullet #${index} must set type to one of: rule, pattern, lesson, compound.`
|
|
1236
|
-
};
|
|
1237
|
-
}
|
|
1238
|
-
const trigger = typeof obj.trigger === "string" ? obj.trigger.trim() : "";
|
|
1239
|
-
if (trigger.length === 0) {
|
|
1240
|
-
return {
|
|
1241
|
-
ok: false,
|
|
1242
|
-
error: `Learnings bullet #${index} must include non-empty "trigger".`
|
|
1243
|
-
};
|
|
1244
|
-
}
|
|
1245
|
-
const action = typeof obj.action === "string" ? obj.action.trim() : "";
|
|
1246
|
-
if (action.length === 0) {
|
|
1247
|
-
return {
|
|
1248
|
-
ok: false,
|
|
1249
|
-
error: `Learnings bullet #${index} must include non-empty "action".`
|
|
1250
|
-
};
|
|
1251
|
-
}
|
|
1252
|
-
const confidence = typeof obj.confidence === "string" ? obj.confidence.toLowerCase() : "";
|
|
1253
|
-
if (!LEARNING_CONFIDENCE_SET.has(confidence)) {
|
|
1254
|
-
return {
|
|
1255
|
-
ok: false,
|
|
1256
|
-
error: `Learnings bullet #${index} must set confidence to high|medium|low.`
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1259
|
-
const severity = typeof obj.severity === "string" ? obj.severity.toLowerCase() : undefined;
|
|
1260
|
-
if (severity !== undefined && !LEARNING_SEVERITY_SET.has(severity)) {
|
|
1261
|
-
return {
|
|
1262
|
-
ok: false,
|
|
1263
|
-
error: `Learnings bullet #${index} field "severity" must be critical|important|suggestion.`
|
|
1264
|
-
};
|
|
1265
|
-
}
|
|
1266
|
-
if (obj.domain !== undefined && !isNullableString(obj.domain)) {
|
|
1267
|
-
return { ok: false, error: `Learnings bullet #${index} field "domain" must be string or null.` };
|
|
1268
|
-
}
|
|
1269
|
-
if (obj.stage !== undefined && !isNullableStage(obj.stage)) {
|
|
1270
|
-
return {
|
|
1271
|
-
ok: false,
|
|
1272
|
-
error: `Learnings bullet #${index} field "stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
if (obj.origin_stage !== undefined && !isNullableStage(obj.origin_stage)) {
|
|
1276
|
-
return {
|
|
1277
|
-
ok: false,
|
|
1278
|
-
error: `Learnings bullet #${index} field "origin_stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
if (obj.origin_run !== undefined && !isNullableString(obj.origin_run)) {
|
|
1282
|
-
return { ok: false, error: `Learnings bullet #${index} field "origin_run" must be string or null.` };
|
|
1283
|
-
}
|
|
1284
|
-
if (obj.origin_feature !== undefined && !isNullableString(obj.origin_feature)) {
|
|
1285
|
-
return { ok: false, error: `Learnings bullet #${index} field "origin_feature" must be string or null.` };
|
|
1286
|
-
}
|
|
1287
|
-
if (obj.project !== undefined && !isNullableString(obj.project)) {
|
|
1288
|
-
return { ok: false, error: `Learnings bullet #${index} field "project" must be string or null.` };
|
|
1289
|
-
}
|
|
1290
|
-
if (obj.source !== undefined &&
|
|
1291
|
-
obj.source !== null &&
|
|
1292
|
-
(typeof obj.source !== "string" || !LEARNING_SOURCE_SET.has(obj.source))) {
|
|
1293
|
-
return {
|
|
1294
|
-
ok: false,
|
|
1295
|
-
error: `Learnings bullet #${index} field "source" must be stage|retro|compound|ideate|manual or null.`
|
|
1296
|
-
};
|
|
1297
|
-
}
|
|
1298
|
-
if (obj.frequency !== undefined &&
|
|
1299
|
-
(typeof obj.frequency !== "number" || !Number.isInteger(obj.frequency) || obj.frequency < 1)) {
|
|
1300
|
-
return { ok: false, error: `Learnings bullet #${index} field "frequency" must be an integer >= 1.` };
|
|
1301
|
-
}
|
|
1302
|
-
if (obj.universality !== undefined &&
|
|
1303
|
-
(typeof obj.universality !== "string" ||
|
|
1304
|
-
!LEARNING_UNIVERSALITY_SET.has(obj.universality))) {
|
|
1305
|
-
return {
|
|
1306
|
-
ok: false,
|
|
1307
|
-
error: `Learnings bullet #${index} field "universality" must be project|personal|universal.`
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
if (obj.maturity !== undefined &&
|
|
1311
|
-
(typeof obj.maturity !== "string" || !LEARNING_MATURITY_SET.has(obj.maturity))) {
|
|
1312
|
-
return {
|
|
1313
|
-
ok: false,
|
|
1314
|
-
error: `Learnings bullet #${index} field "maturity" must be raw|lifted-to-rule|lifted-to-enforcement.`
|
|
1315
|
-
};
|
|
1316
|
-
}
|
|
1317
|
-
for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
|
|
1318
|
-
const value = obj[timestampField];
|
|
1319
|
-
if (value === undefined)
|
|
1320
|
-
continue;
|
|
1321
|
-
if (typeof value !== "string" || !isIsoUtcTimestamp(value)) {
|
|
1322
|
-
return {
|
|
1323
|
-
ok: false,
|
|
1324
|
-
error: `Learnings bullet #${index} field "${timestampField}" must be ISO UTC (YYYY-MM-DDTHH:MM:SSZ).`
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
if (obj.supersedes !== undefined) {
|
|
1329
|
-
if (!Array.isArray(obj.supersedes) ||
|
|
1330
|
-
obj.supersedes.length === 0 ||
|
|
1331
|
-
obj.supersedes.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
1332
|
-
return { ok: false, error: `Learnings bullet #${index} field "supersedes" must be a non-empty array of strings.` };
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
if (obj.superseded_by !== undefined &&
|
|
1336
|
-
(typeof obj.superseded_by !== "string" || obj.superseded_by.trim().length === 0)) {
|
|
1337
|
-
return { ok: false, error: `Learnings bullet #${index} field "superseded_by" must be a non-empty string.` };
|
|
1338
|
-
}
|
|
1339
|
-
return {
|
|
1340
|
-
ok: true,
|
|
1341
|
-
entry: {
|
|
1342
|
-
...obj,
|
|
1343
|
-
type: type,
|
|
1344
|
-
trigger,
|
|
1345
|
-
action,
|
|
1346
|
-
confidence: confidence,
|
|
1347
|
-
...(severity ? { severity: severity } : {})
|
|
1348
|
-
}
|
|
1349
|
-
};
|
|
1350
|
-
}
|
|
1351
|
-
export function parseLearningsSection(sectionBody) {
|
|
1352
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
1353
|
-
const nonEmpty = lines.filter((line) => line.length > 0);
|
|
1354
|
-
const bullets = nonEmpty.filter((line) => /^-\s+\S+/u.test(line));
|
|
1355
|
-
if (bullets.length === 0) {
|
|
1356
|
-
return {
|
|
1357
|
-
ok: false,
|
|
1358
|
-
none: false,
|
|
1359
|
-
entries: [],
|
|
1360
|
-
errors: ["Learnings section must contain bullet entries."],
|
|
1361
|
-
details: "Learnings section must contain bullet entries."
|
|
1362
|
-
};
|
|
1363
|
-
}
|
|
1364
|
-
const nonBulletContent = nonEmpty.filter((line) => !/^-\s+\S+/u.test(line));
|
|
1365
|
-
if (nonBulletContent.length > 0) {
|
|
1366
|
-
return {
|
|
1367
|
-
ok: false,
|
|
1368
|
-
none: false,
|
|
1369
|
-
entries: [],
|
|
1370
|
-
errors: ["Learnings section must only contain bullet lines (one bullet per learning)."],
|
|
1371
|
-
details: "Learnings section must only contain bullet lines (one bullet per learning)."
|
|
1372
|
-
};
|
|
1373
|
-
}
|
|
1374
|
-
if (bullets.length === 1) {
|
|
1375
|
-
const payload = bullets[0].replace(/^-\s+/u, "").trim();
|
|
1376
|
-
if (/^none this stage\.?$/iu.test(payload)) {
|
|
1377
|
-
return {
|
|
1378
|
-
ok: true,
|
|
1379
|
-
none: true,
|
|
1380
|
-
entries: [],
|
|
1381
|
-
errors: [],
|
|
1382
|
-
details: "Learnings section explicitly marked as none."
|
|
1383
|
-
};
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
const entries = [];
|
|
1387
|
-
const errors = [];
|
|
1388
|
-
for (let i = 0; i < bullets.length; i += 1) {
|
|
1389
|
-
const payload = bullets[i].replace(/^-\s+/u, "").trim();
|
|
1390
|
-
let parsed;
|
|
1391
|
-
try {
|
|
1392
|
-
parsed = JSON.parse(payload);
|
|
1393
|
-
}
|
|
1394
|
-
catch (err) {
|
|
1395
|
-
errors.push(`Learnings bullet #${i + 1} must be valid JSON object or "None this stage.": ${err instanceof Error ? err.message : String(err)}`);
|
|
1396
|
-
continue;
|
|
1397
|
-
}
|
|
1398
|
-
const parsedEntry = parseLearningSeedEntry(parsed, i + 1);
|
|
1399
|
-
if (!parsedEntry.ok || !parsedEntry.entry) {
|
|
1400
|
-
errors.push(parsedEntry.error ?? `Learnings bullet #${i + 1} is invalid.`);
|
|
1401
|
-
continue;
|
|
1402
|
-
}
|
|
1403
|
-
entries.push(parsedEntry.entry);
|
|
1404
|
-
}
|
|
1405
|
-
if (errors.length > 0) {
|
|
1406
|
-
return {
|
|
1407
|
-
ok: false,
|
|
1408
|
-
none: false,
|
|
1409
|
-
entries: [],
|
|
1410
|
-
errors,
|
|
1411
|
-
details: errors.join(" | ")
|
|
1412
|
-
};
|
|
1413
|
-
}
|
|
1414
|
-
return {
|
|
1415
|
-
ok: true,
|
|
1416
|
-
none: false,
|
|
1417
|
-
entries,
|
|
1418
|
-
errors: [],
|
|
1419
|
-
details: `Parsed ${entries.length} learning bullet(s) as knowledge-compatible JSON entries.`
|
|
1420
|
-
};
|
|
1421
|
-
}
|
|
1422
|
-
function lineContainsVagueAdjective(text) {
|
|
1423
|
-
const lower = text.toLowerCase();
|
|
1424
|
-
for (const adjective of VAGUE_AC_ADJECTIVES) {
|
|
1425
|
-
const pattern = new RegExp(`(?:^|[^A-Za-z])${adjective.replace(/ /g, "\\s+")}(?:[^A-Za-z]|$)`, "iu");
|
|
1426
|
-
if (pattern.test(lower))
|
|
1427
|
-
return adjective;
|
|
1428
|
-
}
|
|
1429
|
-
return null;
|
|
1430
|
-
}
|
|
6
|
+
import { extractH2Sections, extractLockedDecisionAnchors, extractRequirementIdsFromMarkdown, isShortCircuitActivated, normalizeHeadingTitle, parseFrontmatter, parseLearningsSection, sectionBodyByAnyName, sectionBodyByHeadingPrefix, sectionBodyByName, validateSectionBody } from "./artifact-linter/shared.js";
|
|
7
|
+
import { lintBrainstormStage } from "./artifact-linter/brainstorm.js";
|
|
8
|
+
import { lintDesignStage } from "./artifact-linter/design.js";
|
|
9
|
+
import { lintPlanStage } from "./artifact-linter/plan.js";
|
|
10
|
+
import { lintScopeStage } from "./artifact-linter/scope.js";
|
|
11
|
+
import { lintSpecStage } from "./artifact-linter/spec.js";
|
|
12
|
+
import { lintTddStage } from "./artifact-linter/tdd.js";
|
|
13
|
+
import { lintReviewStage } from "./artifact-linter/review.js";
|
|
14
|
+
import { lintShipStage } from "./artifact-linter/ship.js";
|
|
15
|
+
export { validateReviewArmy, checkReviewVerdictConsistency, checkReviewSecurityNoChangeAttestation } from "./artifact-linter/review-army.js";
|
|
16
|
+
export { extractMarkdownSectionBody, parseLearningsSection } from "./artifact-linter/shared.js";
|
|
1431
17
|
const FRONTMATTER_REQUIRED_KEYS = [
|
|
1432
18
|
"stage",
|
|
1433
19
|
"schema_version",
|
|
@@ -1435,244 +21,6 @@ const FRONTMATTER_REQUIRED_KEYS = [
|
|
|
1435
21
|
"locked_decisions",
|
|
1436
22
|
"inputs_hash"
|
|
1437
23
|
];
|
|
1438
|
-
const PLACEHOLDER_PATTERNS = [
|
|
1439
|
-
{ label: "TODO", regex: /\bTODO\b/iu },
|
|
1440
|
-
{ label: "TBD", regex: /\bTBD\b/iu },
|
|
1441
|
-
{ label: "FIXME", regex: /\bFIXME\b/iu },
|
|
1442
|
-
{ label: "<fill-in>", regex: /<fill-in>/iu },
|
|
1443
|
-
{ label: "<your-*-here>", regex: /<your-[^>]*-here>/iu },
|
|
1444
|
-
{ label: "xxx", regex: /\bxxx\b/iu },
|
|
1445
|
-
{ label: "ellipsis", regex: /\.{3}/u }
|
|
1446
|
-
];
|
|
1447
|
-
const SCOPE_REDUCTION_PATTERNS = [
|
|
1448
|
-
{ label: "v1", regex: /\bv1\b/iu },
|
|
1449
|
-
{ label: "for now", regex: /\bfor now\b/iu },
|
|
1450
|
-
{ label: "later", regex: /\blater\b/iu },
|
|
1451
|
-
{ label: "temporary", regex: /\btemporary\b/iu },
|
|
1452
|
-
{ label: "placeholder", regex: /\bplaceholder\b/iu },
|
|
1453
|
-
{ label: "mock for now", regex: /\bmock for now\b/iu },
|
|
1454
|
-
{ label: "hardcoded for now", regex: /\bhardcoded for now\b/iu },
|
|
1455
|
-
{ label: "will improve later", regex: /\bwill improve later\b/iu }
|
|
1456
|
-
];
|
|
1457
|
-
function parseFrontmatter(markdown) {
|
|
1458
|
-
const lines = markdown.split(/\r?\n/);
|
|
1459
|
-
if (lines[0]?.trim() !== "---") {
|
|
1460
|
-
return { hasFrontmatter: false, values: {} };
|
|
1461
|
-
}
|
|
1462
|
-
const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
1463
|
-
if (endIndex < 0) {
|
|
1464
|
-
return { hasFrontmatter: false, values: {} };
|
|
1465
|
-
}
|
|
1466
|
-
const values = {};
|
|
1467
|
-
for (const line of lines.slice(1, endIndex)) {
|
|
1468
|
-
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line.trim());
|
|
1469
|
-
if (!match)
|
|
1470
|
-
continue;
|
|
1471
|
-
const key = match[1];
|
|
1472
|
-
const value = match[2].trim();
|
|
1473
|
-
values[key] = value;
|
|
1474
|
-
}
|
|
1475
|
-
return { hasFrontmatter: true, values };
|
|
1476
|
-
}
|
|
1477
|
-
function extractDecisionIds(text) {
|
|
1478
|
-
const ids = text.match(/\bD-\d+\b/gu) ?? [];
|
|
1479
|
-
return [...new Set(ids)];
|
|
1480
|
-
}
|
|
1481
|
-
function extractRequirementIdsFromMarkdown(text) {
|
|
1482
|
-
const ids = text.match(/\bR\d+\b/gu) ?? [];
|
|
1483
|
-
return [...new Set(ids)];
|
|
1484
|
-
}
|
|
1485
|
-
function extractLockedDecisionAnchors(text) {
|
|
1486
|
-
const ids = text.match(/\bLD#[0-9a-f]{8}\b/giu) ?? [];
|
|
1487
|
-
return [...new Set(ids.map((id) => id.replace(/^LD#/iu, "LD#").toLowerCase()))];
|
|
1488
|
-
}
|
|
1489
|
-
function lockedDecisionHash(value) {
|
|
1490
|
-
const normalized = value.replace(/\s+/gu, " ").trim().toLowerCase();
|
|
1491
|
-
return `LD#${createHash("sha256").update(normalized).digest("hex").slice(0, 8)}`;
|
|
1492
|
-
}
|
|
1493
|
-
function collectPatternHits(text, patterns) {
|
|
1494
|
-
const hits = [];
|
|
1495
|
-
for (const pattern of patterns) {
|
|
1496
|
-
if (pattern.regex.test(text)) {
|
|
1497
|
-
hits.push(pattern.label);
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
return hits;
|
|
1501
|
-
}
|
|
1502
|
-
function validateSectionBody(sectionBody, rule, sectionName) {
|
|
1503
|
-
const bodyLines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
1504
|
-
const meaningful = meaningfulLineCount(sectionBody);
|
|
1505
|
-
if (meaningful === 0) {
|
|
1506
|
-
return {
|
|
1507
|
-
ok: false,
|
|
1508
|
-
details: "Section exists but has no meaningful content yet."
|
|
1509
|
-
};
|
|
1510
|
-
}
|
|
1511
|
-
const minItems = extractMinItemsFromRule(rule);
|
|
1512
|
-
if (minItems !== null) {
|
|
1513
|
-
const count = countListItems(sectionBody);
|
|
1514
|
-
if (count < minItems) {
|
|
1515
|
-
return {
|
|
1516
|
-
ok: false,
|
|
1517
|
-
details: `Rule expects at least ${minItems} item(s), found ${count}.`
|
|
1518
|
-
};
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
if (/table must use 4 columns/iu.test(rule)) {
|
|
1522
|
-
const header = tableHeaderCells(sectionBody);
|
|
1523
|
-
if (!header) {
|
|
1524
|
-
return {
|
|
1525
|
-
ok: false,
|
|
1526
|
-
details: "Rule expects a markdown table header with a separator row."
|
|
1527
|
-
};
|
|
1528
|
-
}
|
|
1529
|
-
const expected = ["Category", "Question asked", "User answer", "Evidence note"];
|
|
1530
|
-
const normalizedHeader = header.map((cell) => cell.toLowerCase());
|
|
1531
|
-
const normalizedExpected = expected.map((cell) => cell.toLowerCase());
|
|
1532
|
-
const matches = normalizedHeader.length === normalizedExpected.length &&
|
|
1533
|
-
normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
|
|
1534
|
-
if (!matches) {
|
|
1535
|
-
return {
|
|
1536
|
-
ok: false,
|
|
1537
|
-
details: `Rule expects Clarification Log header: ${expected.join(" | ")}.`
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
if (/exactly one/iu.test(rule)) {
|
|
1542
|
-
const tokens = tokensFromRule(rule);
|
|
1543
|
-
if (tokens.length > 0) {
|
|
1544
|
-
const selected = new Set();
|
|
1545
|
-
const tokenLines = [];
|
|
1546
|
-
for (const line of bodyLines) {
|
|
1547
|
-
if (!line)
|
|
1548
|
-
continue;
|
|
1549
|
-
for (const token of tokens) {
|
|
1550
|
-
if (!lineHasToken(line, token))
|
|
1551
|
-
continue;
|
|
1552
|
-
tokenLines.push({ line, token });
|
|
1553
|
-
if (/\[x\]/iu.test(line) || /selected|verdict|enum|execution result|status/iu.test(line)) {
|
|
1554
|
-
selected.add(token);
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
if (selected.size === 0 && tokenLines.length === 1 && !tokenLines[0].line.includes("|")) {
|
|
1559
|
-
selected.add(tokenLines[0].token);
|
|
1560
|
-
}
|
|
1561
|
-
if (selected.size !== 1) {
|
|
1562
|
-
return {
|
|
1563
|
-
ok: false,
|
|
1564
|
-
details: `Rule expects exactly one selected token (${tokens.join(", ")}); found ${selected.size}.`
|
|
1565
|
-
};
|
|
1566
|
-
}
|
|
1567
|
-
return { ok: true, details: "Exactly one token selected as expected." };
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
if (/Status:\s*pending\s+until/iu.test(rule)) {
|
|
1571
|
-
const statusLine = bodyLines.find((l) => /^\s*-?\s*Status\s*:/iu.test(l));
|
|
1572
|
-
if (!statusLine) {
|
|
1573
|
-
return { ok: false, details: "WAIT_FOR_CONFIRM section must contain a 'Status:' line." };
|
|
1574
|
-
}
|
|
1575
|
-
const validStatuses = ["pending", "approved"];
|
|
1576
|
-
const statusMatch = /Status\s*:\s*(\S+)/iu.exec(statusLine);
|
|
1577
|
-
const statusValue = statusMatch?.[1]?.toLowerCase();
|
|
1578
|
-
if (!statusValue || !validStatuses.includes(statusValue)) {
|
|
1579
|
-
const foundLabel = statusValue || "(empty)";
|
|
1580
|
-
return {
|
|
1581
|
-
ok: false,
|
|
1582
|
-
details: "WAIT_FOR_CONFIRM Status must be exactly one of: " + validStatuses.join(", ") + ". Found: " + foundLabel + "."
|
|
1583
|
-
};
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
const sectionNameNormalized = normalizeHeadingTitle(sectionName).toLowerCase();
|
|
1587
|
-
if (sectionNameNormalized === "red evidence") {
|
|
1588
|
-
return validateTddRedEvidence(sectionBody);
|
|
1589
|
-
}
|
|
1590
|
-
if (sectionNameNormalized === "green evidence") {
|
|
1591
|
-
return validateTddGreenEvidence(sectionBody);
|
|
1592
|
-
}
|
|
1593
|
-
if (sectionNameNormalized === "verification ladder") {
|
|
1594
|
-
return validateVerificationLadder(sectionBody);
|
|
1595
|
-
}
|
|
1596
|
-
if (sectionNameNormalized === "failure mode table") {
|
|
1597
|
-
return validateFailureModeTable(sectionBody);
|
|
1598
|
-
}
|
|
1599
|
-
if (sectionNameNormalized === "pre-scope system audit") {
|
|
1600
|
-
return validatePreScopeSystemAudit(sectionBody);
|
|
1601
|
-
}
|
|
1602
|
-
if (sectionNameNormalized === "scope summary") {
|
|
1603
|
-
return validateScopeSummary(sectionBody);
|
|
1604
|
-
}
|
|
1605
|
-
if (sectionNameNormalized === "premise challenge") {
|
|
1606
|
-
return validatePremiseChallenge(sectionBody);
|
|
1607
|
-
}
|
|
1608
|
-
if (sectionNameNormalized.startsWith("requirements")) {
|
|
1609
|
-
return validateRequirementsTaxonomy(sectionBody);
|
|
1610
|
-
}
|
|
1611
|
-
if (sectionNameNormalized === "data flow") {
|
|
1612
|
-
return validateInteractionEdgeCaseMatrix(sectionBody);
|
|
1613
|
-
}
|
|
1614
|
-
if (sectionNameNormalized === "architecture diagram") {
|
|
1615
|
-
const edgeLines = diagramEdgeLines(sectionBody);
|
|
1616
|
-
if (edgeLines.length === 0) {
|
|
1617
|
-
return {
|
|
1618
|
-
ok: false,
|
|
1619
|
-
details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
|
|
1620
|
-
};
|
|
1621
|
-
}
|
|
1622
|
-
if (!hasLabeledDiagramArrow(edgeLines)) {
|
|
1623
|
-
return {
|
|
1624
|
-
ok: false,
|
|
1625
|
-
details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
|
|
1626
|
-
};
|
|
1627
|
-
}
|
|
1628
|
-
const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
|
|
1629
|
-
if (genericLine) {
|
|
1630
|
-
return {
|
|
1631
|
-
ok: false,
|
|
1632
|
-
details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
|
|
1633
|
-
};
|
|
1634
|
-
}
|
|
1635
|
-
if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
|
|
1636
|
-
return {
|
|
1637
|
-
ok: false,
|
|
1638
|
-
details: "Architecture Diagram must distinguish sync vs async edges (for example solid + dotted arrows, or `sync:` and `async:` labels)."
|
|
1639
|
-
};
|
|
1640
|
-
}
|
|
1641
|
-
if (!hasFailureEdgeInDiagram(sectionBody)) {
|
|
1642
|
-
return {
|
|
1643
|
-
ok: false,
|
|
1644
|
-
details: "Architecture Diagram must include at least one failure-edge arrow with a failure keyword (for example: timeout, error, fallback, degraded, retry)."
|
|
1645
|
-
};
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
if (sectionNameNormalized === "acceptance criteria" &&
|
|
1649
|
-
/observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {
|
|
1650
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
1651
|
-
for (const row of rows) {
|
|
1652
|
-
const criterionText = row[1] ?? row[0] ?? "";
|
|
1653
|
-
const adjective = lineContainsVagueAdjective(criterionText);
|
|
1654
|
-
if (adjective) {
|
|
1655
|
-
return {
|
|
1656
|
-
ok: false,
|
|
1657
|
-
details: `Acceptance criterion uses vague adjective "${adjective}" without a measurable predicate: "${criterionText.slice(0, 140)}". Rewrite with a numeric threshold or boolean outcome.`
|
|
1658
|
-
};
|
|
1659
|
-
}
|
|
1660
|
-
const hasDigit = /\d/u.test(criterionText);
|
|
1661
|
-
const hasMeasurableVerb = /\b(blocks?|rejects?|returns?|matches?|equals?|emits?|succeeds?|fails?|publishes?|logs?|persists?|reads?|writes?|creates?|deletes?|throws?|contains?|restores?|exceeds?|responds?|warns?|quarantines?|includes?|raises?|passes?|denies|refuses|exits|succeeds|completes|prevents|allows|maps|points|signals|surfaces|records|produces|accepts|requires)\b/iu.test(criterionText);
|
|
1662
|
-
const hasMeaningfulText = /[A-Za-z]/u.test(criterionText) && criterionText.trim().length >= 12;
|
|
1663
|
-
if (hasMeaningfulText && !hasDigit && !hasMeasurableVerb) {
|
|
1664
|
-
return {
|
|
1665
|
-
ok: false,
|
|
1666
|
-
details: `Acceptance criterion lacks a measurable predicate (no numeric threshold, no observable verb like blocks/returns/publishes/matches): "${criterionText.slice(0, 140)}". Rewrite so the criterion is falsifiable by a single test.`
|
|
1667
|
-
};
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
return {
|
|
1672
|
-
ok: true,
|
|
1673
|
-
details: "Section heading and content satisfy lint heuristics."
|
|
1674
|
-
};
|
|
1675
|
-
}
|
|
1676
24
|
export async function lintArtifact(projectRoot, stage, track = "standard") {
|
|
1677
25
|
const schema = stageSchema(stage, track);
|
|
1678
26
|
const { absPath: absFile, relPath: relFile } = await resolveStageArtifactPath(stage, {
|
|
@@ -1741,10 +89,10 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
|
|
|
1741
89
|
const brainstormShortCircuitActivated = stage === "brainstorm" && isShortCircuitActivated(brainstormShortCircuitBody);
|
|
1742
90
|
const scopePreAuditEnabled = projectConfig.optInAudits?.scopePreAudit === true;
|
|
1743
91
|
const staleDiagramAuditEnabled = projectConfig.optInAudits?.staleDiagramAudit === true;
|
|
1744
|
-
const isTrivialOverride = schema.trivialOverrideSections &&
|
|
92
|
+
const isTrivialOverride = Boolean(schema.trivialOverrideSections &&
|
|
1745
93
|
schema.trivialOverrideSections.length > 0 &&
|
|
1746
94
|
(/trivial.change|mini.design|escape.hatch/iu.test(raw) ||
|
|
1747
|
-
brainstormShortCircuitActivated);
|
|
95
|
+
brainstormShortCircuitActivated));
|
|
1748
96
|
const overrideSet = isTrivialOverride
|
|
1749
97
|
? new Set(schema.trivialOverrideSections.map((s) => normalizeHeadingTitle(s).toLowerCase()))
|
|
1750
98
|
: null;
|
|
@@ -1801,935 +149,49 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
|
|
|
1801
149
|
details: `${learnings.details}${meaningfulStageNoneWarning}`
|
|
1802
150
|
});
|
|
1803
151
|
}
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
details: approachesTaxonomy.rowCount >= 2
|
|
1848
|
-
? `Detected ${approachesTaxonomy.rowCount} approach row(s).`
|
|
1849
|
-
: `Detected ${approachesTaxonomy.rowCount} approach row(s); at least 2 required.`
|
|
1850
|
-
});
|
|
1851
|
-
findings.push({
|
|
1852
|
-
section: "Approaches Role/Upside Taxonomy",
|
|
1853
|
-
required: true,
|
|
1854
|
-
rule: "Approaches table must use canonical Role and Upside enum values.",
|
|
1855
|
-
found: approachesTaxonomy.roleUpsideOk,
|
|
1856
|
-
details: approachesTaxonomy.details
|
|
1857
|
-
});
|
|
1858
|
-
findings.push({
|
|
1859
|
-
section: "Challenger Alternative Enforcement",
|
|
1860
|
-
required: true,
|
|
1861
|
-
rule: "Approaches must include one challenger option with explicit high/higher upside.",
|
|
1862
|
-
found: approachesTaxonomy.challengerOk,
|
|
1863
|
-
details: approachesTaxonomy.details
|
|
1864
|
-
});
|
|
1865
|
-
}
|
|
1866
|
-
const reactionIndex = headingLineIndex(raw, "Approach Reaction");
|
|
1867
|
-
const directionIndex = headingLineIndex(raw, "Selected Direction");
|
|
1868
|
-
if (directionIndex >= 0 && !brainstormShortCircuitActivated) {
|
|
1869
|
-
const orderOk = reactionIndex >= 0 && reactionIndex < directionIndex;
|
|
1870
|
-
findings.push({
|
|
1871
|
-
section: "Approach Reaction Ordering",
|
|
1872
|
-
required: true,
|
|
1873
|
-
rule: "Approach Reaction must appear before Selected Direction (propose -> react -> recommend).",
|
|
1874
|
-
found: orderOk,
|
|
1875
|
-
details: orderOk
|
|
1876
|
-
? "Approach Reaction appears before Selected Direction."
|
|
1877
|
-
: "Approach Reaction must be present before Selected Direction."
|
|
1878
|
-
});
|
|
1879
|
-
}
|
|
1880
|
-
const directionBody = sectionBodyByName(sections, "Selected Direction");
|
|
1881
|
-
if (directionBody !== null) {
|
|
1882
|
-
const approvalMarker = /\bapprov(?:ed|al)\b/iu.test(directionBody);
|
|
1883
|
-
findings.push({
|
|
1884
|
-
section: "Direction Approval Marker",
|
|
1885
|
-
required: true,
|
|
1886
|
-
rule: "Selected Direction section must state an explicit approval marker (for example `Approval: approved` or `Approved by: user`).",
|
|
1887
|
-
found: approvalMarker,
|
|
1888
|
-
details: approvalMarker
|
|
1889
|
-
? "Approval marker present in Selected Direction."
|
|
1890
|
-
: "No explicit `approved`/`approval` marker found in Selected Direction."
|
|
1891
|
-
});
|
|
1892
|
-
if (!brainstormShortCircuitActivated) {
|
|
1893
|
-
const reactionBody = sectionBodyByName(sections, "Approach Reaction");
|
|
1894
|
-
const reactionTrace = /\b(?:reaction|feedback|concern(?:s)?)\b/iu.test(directionBody) ||
|
|
1895
|
-
(reactionIndex >= 0 && reactionIndex < directionIndex && meaningfulLineCount(reactionBody ?? "") > 0);
|
|
1896
|
-
findings.push({
|
|
1897
|
-
section: "Direction Reaction Trace",
|
|
1898
|
-
required: true,
|
|
1899
|
-
rule: "Selected Direction must be traceable to a prior Approach Reaction section or explicitly reference user reaction/feedback/concerns.",
|
|
1900
|
-
found: reactionTrace,
|
|
1901
|
-
details: reactionTrace
|
|
1902
|
-
? "Selected Direction is traceable to prior user reaction."
|
|
1903
|
-
: "Selected Direction is not traceable to user reaction. Add `## Approach Reaction` before it, or mention the user's reaction/concerns in the rationale."
|
|
1904
|
-
});
|
|
1905
|
-
// Track-aware handoff: standard track goes to `scope`; medium track
|
|
1906
|
-
// goes directly to `spec`; the quick track skips brainstorm entirely.
|
|
1907
|
-
// We accept either canonical successor token plus a generic
|
|
1908
|
-
// `next-stage` / `handoff` phrase to preserve i18n flexibility.
|
|
1909
|
-
const handoffTrace = /(?:`(?:scope|spec)`|\bscope\b|\bspec\b|next[-\s_]stage|next stage|\bhandoff\b|hand[-\s]off)/iu.test(directionBody);
|
|
1910
|
-
findings.push({
|
|
1911
|
-
section: "Direction Next-Stage Handoff",
|
|
1912
|
-
required: true,
|
|
1913
|
-
rule: "Selected Direction must record the track-aware next-stage handoff (mention `scope` for standard, `spec` for medium, or include a `Next-stage handoff:` line).",
|
|
1914
|
-
found: handoffTrace,
|
|
1915
|
-
details: handoffTrace
|
|
1916
|
-
? "Selected Direction names the next-stage handoff."
|
|
1917
|
-
: "Selected Direction is missing a next-stage handoff token. Mention `scope` (standard) or `spec` (medium), or add a `Next-stage handoff:` line so downstream stages can trace the contract."
|
|
1918
|
-
});
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
const shortCircuitBody = brainstormShortCircuitBody;
|
|
1922
|
-
if (shortCircuitBody !== null) {
|
|
1923
|
-
const statusValue = parseShortCircuitStatus(shortCircuitBody);
|
|
1924
|
-
const hasStatus = statusValue.length > 0;
|
|
1925
|
-
findings.push({
|
|
1926
|
-
section: "Short-Circuit Status",
|
|
1927
|
-
required: true,
|
|
1928
|
-
rule: "Short-Circuit Decision must include a `Status:` line (`activated` or `bypassed`).",
|
|
1929
|
-
found: hasStatus,
|
|
1930
|
-
details: hasStatus
|
|
1931
|
-
? `Short-circuit status declared as "${statusValue}".`
|
|
1932
|
-
: "Short-Circuit Decision is missing a `Status:` line."
|
|
1933
|
-
});
|
|
1934
|
-
if (brainstormShortCircuitActivated) {
|
|
1935
|
-
const artifactLines = meaningfulLineCount(raw);
|
|
1936
|
-
const withinStubLimit = artifactLines <= 30;
|
|
1937
|
-
const hasScopeHandoff = /\bscope\b/iu.test(shortCircuitBody);
|
|
1938
|
-
findings.push({
|
|
1939
|
-
section: "Short-Circuit Stub Size",
|
|
1940
|
-
required: true,
|
|
1941
|
-
rule: "When short-circuit is activated, brainstorm artifact must remain a <=30 meaningful-line stub.",
|
|
1942
|
-
found: withinStubLimit,
|
|
1943
|
-
details: withinStubLimit
|
|
1944
|
-
? `Short-circuit stub size within limit (${artifactLines} meaningful lines).`
|
|
1945
|
-
: `Short-circuit stub too large (${artifactLines} meaningful lines); expected <= 30.`
|
|
1946
|
-
});
|
|
1947
|
-
findings.push({
|
|
1948
|
-
section: "Short-Circuit Scope Handoff",
|
|
1949
|
-
required: true,
|
|
1950
|
-
rule: "When short-circuit is activated, the section must explicitly hand off to scope.",
|
|
1951
|
-
found: hasScopeHandoff,
|
|
1952
|
-
details: hasScopeHandoff
|
|
1953
|
-
? "Short-circuit section includes explicit scope handoff."
|
|
1954
|
-
: "Short-circuit section is missing explicit scope handoff guidance."
|
|
1955
|
-
});
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
const selfReviewBody = sectionBodyByName(sections, "Self-Review Notes");
|
|
1959
|
-
if (selfReviewBody !== null) {
|
|
1960
|
-
const selfReview = validateCalibratedSelfReview(selfReviewBody);
|
|
1961
|
-
findings.push({
|
|
1962
|
-
section: "Calibrated Self-Review Format",
|
|
1963
|
-
required: true,
|
|
1964
|
-
rule: "When Self-Review Notes are present, they must use the calibrated review prompt output shape.",
|
|
1965
|
-
found: selfReview.ok,
|
|
1966
|
-
details: selfReview.details
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
// Universal structural checks (Layer 2.1). Each fires only when the
|
|
1970
|
-
// matching section is present so legacy fixtures keep their current
|
|
1971
|
-
// shape, while artifacts emitted from the v3 template have to satisfy
|
|
1972
|
-
// them. Content is never inspected — only the shape required by the
|
|
1973
|
-
// reference patterns (gstack mode, forcing questions, premise list,
|
|
1974
|
-
// approach detail cards, anti-sycophancy stamp).
|
|
1975
|
-
const modeBody = sectionBodyByName(sections, "Mode Block");
|
|
1976
|
-
if (modeBody !== null) {
|
|
1977
|
-
const modeTokens = ["STARTUP", "BUILDER", "ENGINEERING", "OPS", "RESEARCH"];
|
|
1978
|
-
const modeRegex = markdownFieldRegex("Mode", modeTokens.join("|"), "u");
|
|
1979
|
-
const tokenMatches = new Set();
|
|
1980
|
-
const lineRegex = new RegExp(modeRegex.source, "gu");
|
|
1981
|
-
for (const match of modeBody.matchAll(lineRegex)) {
|
|
1982
|
-
const token = (match[0].match(/STARTUP|BUILDER|ENGINEERING|OPS|RESEARCH/u) ?? [""])[0];
|
|
1983
|
-
if (token)
|
|
1984
|
-
tokenMatches.add(token);
|
|
1985
|
-
}
|
|
1986
|
-
const placeholderLine = modeBody
|
|
1987
|
-
.split("\n")
|
|
1988
|
-
.find((line) => /\bMode\b\s*[*_]{0,2}\s*:/iu.test(line) && (line.match(/STARTUP|BUILDER|ENGINEERING|OPS|RESEARCH/giu) ?? []).length >= 2);
|
|
1989
|
-
const isPlaceholder = Boolean(placeholderLine);
|
|
1990
|
-
const ok = tokenMatches.size === 1 && !isPlaceholder;
|
|
1991
|
-
findings.push({
|
|
1992
|
-
section: "Mode Block Token",
|
|
1993
|
-
required: true,
|
|
1994
|
-
rule: "Mode Block must declare exactly one mode token: STARTUP, BUILDER, ENGINEERING, OPS, or RESEARCH.",
|
|
1995
|
-
found: ok,
|
|
1996
|
-
details: ok
|
|
1997
|
-
? `Recognized mode token detected: ${[...tokenMatches][0] ?? ""}.`
|
|
1998
|
-
: isPlaceholder
|
|
1999
|
-
? "Mode Block still lists multiple mode tokens (template placeholder); pick exactly one of STARTUP/BUILDER/ENGINEERING/OPS/RESEARCH."
|
|
2000
|
-
: "Mode Block is missing a recognized mode token (STARTUP/BUILDER/ENGINEERING/OPS/RESEARCH)."
|
|
2001
|
-
});
|
|
2002
|
-
}
|
|
2003
|
-
const forcingBody = sectionBodyByName(sections, "Forcing Questions");
|
|
2004
|
-
if (forcingBody !== null) {
|
|
2005
|
-
const tableRows = forcingBody
|
|
2006
|
-
.split("\n")
|
|
2007
|
-
.filter((line) => /^\|\s*\d+\s*\|/u.test(line));
|
|
2008
|
-
const enoughRows = tableRows.length >= 3;
|
|
2009
|
-
findings.push({
|
|
2010
|
-
section: "Forcing Questions Count",
|
|
2011
|
-
required: true,
|
|
2012
|
-
rule: "Forcing Questions must include at least 3 numbered rows.",
|
|
2013
|
-
found: enoughRows,
|
|
2014
|
-
details: enoughRows
|
|
2015
|
-
? `Detected ${tableRows.length} forcing-question row(s).`
|
|
2016
|
-
: `Detected ${tableRows.length} forcing-question row(s); at least 3 required.`
|
|
2017
|
-
});
|
|
2018
|
-
// A "specific" answer is signalled by at least one of: numeric token,
|
|
2019
|
-
// backticked path/identifier, http(s) link, @mention/role, or quoted
|
|
2020
|
-
// verbatim string. We check structural shape, not content.
|
|
2021
|
-
const specificTokenRegex = /(\d|`[^`]+`|https?:\/\/|@[A-Za-z][\w-]*|"[^"]+"|'[^']+')/u;
|
|
2022
|
-
const allRowsSpecific = tableRows.every((row) => {
|
|
2023
|
-
const cells = row.split("|").map((cell) => cell.trim());
|
|
2024
|
-
// cells: ["", "#", "Question", "Answer", "Decision impact", "Q<n> decision", ""]
|
|
2025
|
-
const answer = cells[3] ?? "";
|
|
2026
|
-
return answer.length > 0 && specificTokenRegex.test(answer);
|
|
2027
|
-
});
|
|
2028
|
-
findings.push({
|
|
2029
|
-
section: "Forcing Questions Specific Answers",
|
|
2030
|
-
required: true,
|
|
2031
|
-
rule: "Each Forcing Questions row must include a specific token in the answer column (number, backticked path, link, @mention, or quoted string).",
|
|
2032
|
-
found: tableRows.length === 0 ? false : allRowsSpecific,
|
|
2033
|
-
details: tableRows.length === 0
|
|
2034
|
-
? "No rows to evaluate."
|
|
2035
|
-
: allRowsSpecific
|
|
2036
|
-
? "All rows include a specific-answer token."
|
|
2037
|
-
: "At least one row's answer is missing a specific-answer token (number, `path`, https link, @mention, or quoted string)."
|
|
2038
|
-
});
|
|
2039
|
-
const decisionRows = (forcingBody.match(/decision\s*:/giu) ?? []).length;
|
|
2040
|
-
findings.push({
|
|
2041
|
-
section: "Forcing Questions STOP-per-issue",
|
|
2042
|
-
required: true,
|
|
2043
|
-
rule: "Each forcing-question row must record a `decision:` marker (STOP-per-issue protocol).",
|
|
2044
|
-
found: decisionRows >= tableRows.length && tableRows.length > 0,
|
|
2045
|
-
details: tableRows.length === 0
|
|
2046
|
-
? "No rows to evaluate."
|
|
2047
|
-
: `Detected ${decisionRows} decision marker(s) for ${tableRows.length} forcing-question row(s).`
|
|
2048
|
-
});
|
|
2049
|
-
}
|
|
2050
|
-
const premiseBody = sectionBodyByName(sections, "Premise List");
|
|
2051
|
-
if (premiseBody !== null) {
|
|
2052
|
-
const premiseRowRegex = /^[-*]\s*P\d+:\s+.+\s+—\s+(agreed|disagreed|revised)\b/imu;
|
|
2053
|
-
const allRows = premiseBody
|
|
2054
|
-
.split("\n")
|
|
2055
|
-
.filter((line) => /^[-*]\s*P\d+:/u.test(line));
|
|
2056
|
-
const validRows = allRows.filter((row) => premiseRowRegex.test(row.trim() + "\n"));
|
|
2057
|
-
const enoughPremises = validRows.length >= 2;
|
|
2058
|
-
findings.push({
|
|
2059
|
-
section: "Premise List Shape",
|
|
2060
|
-
required: true,
|
|
2061
|
-
rule: "Premise List must contain at least 2 rows in the form `P<n>: <statement> — agreed|disagreed|revised`.",
|
|
2062
|
-
found: enoughPremises,
|
|
2063
|
-
details: enoughPremises
|
|
2064
|
-
? `Detected ${validRows.length} valid premise row(s).`
|
|
2065
|
-
: `Detected ${validRows.length} valid premise row(s); at least 2 required (form: \`P<n>: ... — agreed|disagreed|revised\`).`
|
|
2066
|
-
});
|
|
2067
|
-
}
|
|
2068
|
-
// Approach Detail Cards: structural sub-section under Approaches, one
|
|
2069
|
-
// bullet block per approach with the canonical fields.
|
|
2070
|
-
const approachCardsRegex = /####\s+APPROACH\s+[A-Z]\b[\s\S]*?(?:^-\s*Summary:[\s\S]*?^-\s*Effort:[\s\S]*?^-\s*Risk:[\s\S]*?^-\s*Pros:[\s\S]*?^-\s*Cons:[\s\S]*?^-\s*Reuses:)/gimu;
|
|
2071
|
-
const matches = raw.match(approachCardsRegex);
|
|
2072
|
-
const cardCount = matches ? matches.length : 0;
|
|
2073
|
-
if (/####\s+APPROACH\s+[A-Z]\b/iu.test(raw) ||
|
|
2074
|
-
/^RECOMMENDATION:/imu.test(raw)) {
|
|
2075
|
-
findings.push({
|
|
2076
|
-
section: "Approach Detail Cards",
|
|
2077
|
-
required: true,
|
|
2078
|
-
rule: "Approach Detail Cards must include ≥2 `#### APPROACH <letter>` blocks each with Summary/Effort/Risk/Pros/Cons/Reuses.",
|
|
2079
|
-
found: cardCount >= 2,
|
|
2080
|
-
details: cardCount >= 2
|
|
2081
|
-
? `Detected ${cardCount} valid approach detail card(s).`
|
|
2082
|
-
: `Detected ${cardCount} valid approach detail card(s); at least 2 required with all fields present.`
|
|
2083
|
-
});
|
|
2084
|
-
const recommendationLine = raw.match(/^RECOMMENDATION:\s*(.+)$/imu);
|
|
2085
|
-
const hasRecommendation = recommendationLine !== null && recommendationLine[1] !== undefined && recommendationLine[1].trim().length > 0;
|
|
2086
|
-
findings.push({
|
|
2087
|
-
section: "Approach Recommendation Marker",
|
|
2088
|
-
required: true,
|
|
2089
|
-
rule: "Approach Detail Cards must conclude with a single `RECOMMENDATION:` line citing the chosen letter and rationale.",
|
|
2090
|
-
found: hasRecommendation,
|
|
2091
|
-
details: hasRecommendation
|
|
2092
|
-
? "Recommendation marker present."
|
|
2093
|
-
: "Missing or empty `RECOMMENDATION:` line after approach detail cards."
|
|
2094
|
-
});
|
|
2095
|
-
}
|
|
2096
|
-
const stampBody = sectionBodyByName(sections, "Anti-Sycophancy Stamp");
|
|
2097
|
-
if (stampBody !== null) {
|
|
2098
|
-
const acknowledged = markdownFieldRegex("Forbidden response openers acknowledged", "yes|true|y").test(stampBody);
|
|
2099
|
-
findings.push({
|
|
2100
|
-
section: "Anti-Sycophancy Acknowledgement",
|
|
2101
|
-
required: true,
|
|
2102
|
-
rule: "Anti-Sycophancy Stamp must affirm `Forbidden response openers acknowledged: yes`.",
|
|
2103
|
-
found: acknowledged,
|
|
2104
|
-
details: acknowledged
|
|
2105
|
-
? "Anti-sycophancy commitment is acknowledged."
|
|
2106
|
-
: "Anti-Sycophancy Stamp is missing the explicit `Forbidden response openers acknowledged: yes` marker."
|
|
2107
|
-
});
|
|
2108
|
-
}
|
|
2109
|
-
const outsideVoiceBody = sectionBodyByName(sections, "Outside Voice");
|
|
2110
|
-
if (outsideVoiceBody !== null) {
|
|
2111
|
-
const required = ["source:", "prompt:", "tension:", "resolution:"];
|
|
2112
|
-
const missing = required.filter((key) => !new RegExp(`(?:^|\\n)\\s*-?\\s*${key.replace(":", "\\s*:")}`, "iu").test(outsideVoiceBody));
|
|
2113
|
-
const optedOut = /\bnot used\b|\bn\/a\b|\bnone\b/iu.test(outsideVoiceBody);
|
|
2114
|
-
findings.push({
|
|
2115
|
-
section: "Outside Voice Slot Shape",
|
|
2116
|
-
required: true,
|
|
2117
|
-
rule: "Outside Voice section must either declare opt-out (`not used`/`none`) or include `source:`, `prompt:`, `tension:`, `resolution:`.",
|
|
2118
|
-
found: optedOut || missing.length === 0,
|
|
2119
|
-
details: optedOut || missing.length === 0
|
|
2120
|
-
? "Outside Voice slot is well-formed."
|
|
2121
|
-
: `Outside Voice section is missing field(s): ${missing.join(", ")}.`
|
|
2122
|
-
});
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
if (stage === "design") {
|
|
2126
|
-
const tierResolution = await resolveDesignDiagramTier(projectRoot, track, raw);
|
|
2127
|
-
const diagramTier = isTrivialOverride
|
|
2128
|
-
? "lightweight"
|
|
2129
|
-
: tierResolution.tier;
|
|
2130
|
-
const tierSource = isTrivialOverride
|
|
2131
|
-
? `${tierResolution.source}; trivial override forced lightweight`
|
|
2132
|
-
: tierResolution.source;
|
|
2133
|
-
for (const requirement of DESIGN_DIAGRAM_REQUIREMENTS[diagramTier]) {
|
|
2134
|
-
const sectionBody = sectionBodyByName(sections, requirement.section);
|
|
2135
|
-
const hasSection = sectionBody !== null;
|
|
2136
|
-
const escapedMarker = requirement.marker.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
2137
|
-
const markerRegex = new RegExp(`<!--\\s*diagram:\\s*${escapedMarker}\\s*-->`, "iu");
|
|
2138
|
-
const hasMarker = sectionBody !== null && markerRegex.test(sectionBody);
|
|
2139
|
-
const hasContent = sectionBody !== null && meaningfulLineCount(sectionBody) > 0;
|
|
2140
|
-
const found = hasSection && hasMarker && hasContent;
|
|
2141
|
-
findings.push({
|
|
2142
|
-
section: `Diagram Requirement: ${requirement.section}`,
|
|
2143
|
-
required: true,
|
|
2144
|
-
rule: `Design tier "${diagramTier}" requires "${requirement.section}" with marker \`<!-- diagram: ${requirement.marker} -->\`. ${requirement.note}`,
|
|
2145
|
-
found,
|
|
2146
|
-
details: found
|
|
2147
|
-
? `Satisfied (${tierSource}).`
|
|
2148
|
-
: !hasSection
|
|
2149
|
-
? `Missing section "${requirement.section}" (${tierSource}).`
|
|
2150
|
-
: !hasMarker
|
|
2151
|
-
? `Missing marker \`<!-- diagram: ${requirement.marker} -->\` in section "${requirement.section}" (${tierSource}).`
|
|
2152
|
-
: `Section "${requirement.section}" has marker but no meaningful content (${tierSource}).`
|
|
2153
|
-
});
|
|
2154
|
-
}
|
|
2155
|
-
if (staleDiagramAuditEnabled) {
|
|
2156
|
-
const codebaseInvestigation = sectionBodyByName(sections, "Codebase Investigation");
|
|
2157
|
-
if (codebaseInvestigation === null) {
|
|
2158
|
-
findings.push({
|
|
2159
|
-
section: "Stale Diagram Drift Check",
|
|
2160
|
-
required: true,
|
|
2161
|
-
rule: "When `.cclaw/config.yaml::optInAudits.staleDiagramAudit` is true, stale diagram audit requires Codebase Investigation blast-radius files.",
|
|
2162
|
-
found: false,
|
|
2163
|
-
details: "No ## heading matching required section \"Codebase Investigation\"."
|
|
2164
|
-
});
|
|
2165
|
-
}
|
|
2166
|
-
else {
|
|
2167
|
-
const staleAudit = await runStaleDiagramAudit(projectRoot, absFile, raw, codebaseInvestigation);
|
|
2168
|
-
findings.push({
|
|
2169
|
-
section: "Stale Diagram Drift Check",
|
|
2170
|
-
required: true,
|
|
2171
|
-
rule: "When `.cclaw/config.yaml::optInAudits.staleDiagramAudit` is true, blast-radius files must not be newer than current design diagram baseline.",
|
|
2172
|
-
found: staleAudit.ok,
|
|
2173
|
-
details: staleAudit.details
|
|
2174
|
-
});
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
// Universal Layer 2.3 structural checks (gstack plan-eng-review). All
|
|
2178
|
-
// present-only. Validates ASCII coverage diagram tokens, regression iron
|
|
2179
|
-
// rule acknowledgment, and confidence-calibrated finding format.
|
|
2180
|
-
const coverageBody = sectionBodyByName(sections, "ASCII Coverage Diagram");
|
|
2181
|
-
if (coverageBody !== null) {
|
|
2182
|
-
const tokens = ["[★★★]", "[★★]", "[★]", "[GAP]", "[→E2E]", "[→EVAL]"];
|
|
2183
|
-
const presentTokens = tokens.filter((token) => coverageBody.includes(token));
|
|
2184
|
-
const ok = presentTokens.length >= 3;
|
|
2185
|
-
findings.push({
|
|
2186
|
-
section: "ASCII Coverage Diagram Tokens",
|
|
2187
|
-
required: true,
|
|
2188
|
-
rule: "ASCII Coverage Diagram must use the canonical marker tokens (at least 3 of `[★★★]` / `[★★]` / `[★]` / `[GAP]` / `[→E2E]` / `[→EVAL]`).",
|
|
2189
|
-
found: ok,
|
|
2190
|
-
details: ok
|
|
2191
|
-
? `Detected ${presentTokens.length} canonical marker token(s).`
|
|
2192
|
-
: `Detected ${presentTokens.length} canonical marker token(s); at least 3 required.`
|
|
2193
|
-
});
|
|
2194
|
-
}
|
|
2195
|
-
const regressionBody = sectionBodyByName(sections, "Regression Iron Rule");
|
|
2196
|
-
if (regressionBody !== null) {
|
|
2197
|
-
const ack = markdownFieldRegex("Iron rule acknowledged", "yes|true|y").test(regressionBody);
|
|
2198
|
-
findings.push({
|
|
2199
|
-
section: "Regression Iron Rule Acknowledgement",
|
|
2200
|
-
required: true,
|
|
2201
|
-
rule: "Regression Iron Rule section must affirm `Iron rule acknowledged: yes`.",
|
|
2202
|
-
found: ack,
|
|
2203
|
-
details: ack
|
|
2204
|
-
? "Regression iron rule acknowledged."
|
|
2205
|
-
: "Regression Iron Rule is missing explicit `Iron rule acknowledged: yes`."
|
|
2206
|
-
});
|
|
2207
|
-
}
|
|
2208
|
-
const findingsBody = sectionBodyByName(sections, "Calibrated Findings");
|
|
2209
|
-
if (findingsBody !== null) {
|
|
2210
|
-
const isEmpty = /(^|\n)\s*-\s*None this stage\b/iu.test(findingsBody);
|
|
2211
|
-
const findingRegex = new RegExp(CONFIDENCE_FINDING_REGEX_SOURCE, "u");
|
|
2212
|
-
const validRows = findingsBody
|
|
2213
|
-
.split("\n")
|
|
2214
|
-
.filter((line) => /^[-*]\s+\[/u.test(line.trim()))
|
|
2215
|
-
.filter((line) => findingRegex.test(line));
|
|
2216
|
-
const ok = isEmpty || validRows.length >= 1;
|
|
2217
|
-
findings.push({
|
|
2218
|
-
section: "Calibrated Finding Format",
|
|
2219
|
-
required: true,
|
|
2220
|
-
rule: "Calibrated Findings must either declare `None this stage` or contain at least one finding in the form `[P1|P2|P3] (confidence: <n>/10) <path>[:<line>] — <description>`.",
|
|
2221
|
-
found: ok,
|
|
2222
|
-
details: isEmpty
|
|
2223
|
-
? "No findings recorded for this stage."
|
|
2224
|
-
: ok
|
|
2225
|
-
? `Detected ${validRows.length} calibrated finding(s).`
|
|
2226
|
-
: "No calibrated findings detected. Use `[P1|P2|P3] (confidence: <n>/10) <repo-path>[:<line>] — <description>`."
|
|
2227
|
-
});
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
if (stage === "plan") {
|
|
2231
|
-
const strictPlanGuards = parsedFrontmatter.hasFrontmatter ||
|
|
2232
|
-
headingPresent(sections, "No-Placeholder Scan") ||
|
|
2233
|
-
headingPresent(sections, "No Scope Reduction Language Scan") ||
|
|
2234
|
-
headingPresent(sections, "Locked Decision Coverage");
|
|
2235
|
-
const taskListBody = sectionBodyByName(sections, "Task List") ?? raw;
|
|
2236
|
-
const placeholderHits = collectPatternHits(taskListBody, PLACEHOLDER_PATTERNS);
|
|
2237
|
-
findings.push({
|
|
2238
|
-
section: "No Placeholder Enforcement",
|
|
2239
|
-
required: strictPlanGuards,
|
|
2240
|
-
rule: "Task List must not contain placeholders (TODO/TBD/FIXME/<fill-in>/<your-*-here>/xxx/ellipsis).",
|
|
2241
|
-
found: placeholderHits.length === 0,
|
|
2242
|
-
details: placeholderHits.length === 0
|
|
2243
|
-
? "No placeholder tokens detected in Task List."
|
|
2244
|
-
: `Detected placeholder token(s) in Task List: ${placeholderHits.join(", ")}.`
|
|
2245
|
-
});
|
|
2246
|
-
const scopeArtifact = await resolveStageArtifactPath("scope", {
|
|
2247
|
-
projectRoot,
|
|
2248
|
-
track,
|
|
2249
|
-
intent: "read"
|
|
2250
|
-
});
|
|
2251
|
-
const scopeRaw = (await exists(scopeArtifact.absPath))
|
|
2252
|
-
? await fs.readFile(scopeArtifact.absPath, "utf8")
|
|
2253
|
-
: "";
|
|
2254
|
-
const scopeDecisionIds = extractDecisionIds(scopeRaw);
|
|
2255
|
-
const missingDecisionRefs = scopeDecisionIds.filter((id) => !raw.includes(id));
|
|
2256
|
-
findings.push({
|
|
2257
|
-
section: "Locked Decision Traceability",
|
|
2258
|
-
required: strictPlanGuards && scopeDecisionIds.length > 0,
|
|
2259
|
-
rule: "Every locked decision ID (D-XX) in scope must be referenced in plan.",
|
|
2260
|
-
found: missingDecisionRefs.length === 0,
|
|
2261
|
-
details: scopeDecisionIds.length === 0
|
|
2262
|
-
? "No D-XX IDs found in scope artifact; traceability check skipped."
|
|
2263
|
-
: missingDecisionRefs.length === 0
|
|
2264
|
-
? `All ${scopeDecisionIds.length} scope decision IDs are referenced in plan.`
|
|
2265
|
-
: `Missing scope decision reference(s) in plan: ${missingDecisionRefs.join(", ")}.`
|
|
2266
|
-
});
|
|
2267
|
-
const reductionHits = collectPatternHits(taskListBody, SCOPE_REDUCTION_PATTERNS);
|
|
2268
|
-
findings.push({
|
|
2269
|
-
section: "No Scope Reduction Language",
|
|
2270
|
-
required: strictPlanGuards && scopeDecisionIds.length > 0,
|
|
2271
|
-
rule: "Task List must not include scope-reduction language when locked decisions exist.",
|
|
2272
|
-
found: reductionHits.length === 0,
|
|
2273
|
-
details: scopeDecisionIds.length === 0
|
|
2274
|
-
? "No locked decisions found in scope artifact; scope-reduction scan is advisory."
|
|
2275
|
-
: reductionHits.length === 0
|
|
2276
|
-
? "No scope-reduction phrases detected in Task List."
|
|
2277
|
-
: `Detected scope-reduction phrase(s) in Task List: ${reductionHits.join(", ")}.`
|
|
2278
|
-
});
|
|
2279
|
-
// Universal Layer 2.5 structural checks (superpowers writing-plans + ce-plan).
|
|
2280
|
-
// Plan-wide placeholder scan (broader than Task List) using the
|
|
2281
|
-
// FORBIDDEN_PLACEHOLDER_TOKENS list shared with the cross-cutting block.
|
|
2282
|
-
const planHeaderBody = sectionBodyByName(sections, "Plan Header");
|
|
2283
|
-
if (planHeaderBody !== null) {
|
|
2284
|
-
const required = ["Goal:", "Architecture:", "Tech Stack:"];
|
|
2285
|
-
const missing = required.filter((token) => !new RegExp(token.replace(":", "\\s*:"), "iu").test(planHeaderBody));
|
|
2286
|
-
findings.push({
|
|
2287
|
-
section: "Plan Header Coverage",
|
|
2288
|
-
required: true,
|
|
2289
|
-
rule: "Plan Header must include Goal, Architecture, and Tech Stack lines.",
|
|
2290
|
-
found: missing.length === 0,
|
|
2291
|
-
details: missing.length === 0
|
|
2292
|
-
? "Plan Header covers Goal/Architecture/Tech Stack."
|
|
2293
|
-
: `Plan Header is missing field(s): ${missing.join(", ")}.`
|
|
2294
|
-
});
|
|
2295
|
-
}
|
|
2296
|
-
const unitBlocks = raw.match(/###\s+Implementation Unit\s+U-\d+/giu) ?? [];
|
|
2297
|
-
if (unitBlocks.length > 0) {
|
|
2298
|
-
const requiredKeys = ["Goal:", "Files", "Approach:", "Test scenarios:", "Verification:"];
|
|
2299
|
-
const blockBodies = raw.split(/(?=###\s+Implementation Unit\s+U-\d+)/iu).slice(1);
|
|
2300
|
-
const validBlocks = blockBodies.filter((block) => requiredKeys.every((key) => new RegExp(key.replace(":", "\\s*:"), "iu").test(block)));
|
|
2301
|
-
findings.push({
|
|
2302
|
-
section: "Implementation Unit Shape",
|
|
2303
|
-
required: true,
|
|
2304
|
-
rule: "Each `### Implementation Unit U-<n>` must include Goal, Files, Approach, Test scenarios, Verification.",
|
|
2305
|
-
found: validBlocks.length === unitBlocks.length,
|
|
2306
|
-
details: validBlocks.length === unitBlocks.length
|
|
2307
|
-
? `All ${unitBlocks.length} implementation unit(s) include the required fields.`
|
|
2308
|
-
: `${unitBlocks.length - validBlocks.length} implementation unit(s) are missing required fields.`
|
|
2309
|
-
});
|
|
2310
|
-
}
|
|
2311
|
-
const allPlaceholderTokens = FORBIDDEN_PLACEHOLDER_TOKENS.map((token) => token.toLowerCase());
|
|
2312
|
-
const lowerRaw = raw.toLowerCase();
|
|
2313
|
-
const planWidePlaceholderHits = allPlaceholderTokens.filter((token) => lowerRaw.includes(token));
|
|
2314
|
-
// Strip the "## NO PLACEHOLDERS Rule" section (which lists tokens) and
|
|
2315
|
-
// any acknowledgement text from the scan to avoid false positives where
|
|
2316
|
-
// the plan deliberately references the rule by name.
|
|
2317
|
-
const placeholderRuleSection = sectionBodyByName(sections, "NO PLACEHOLDERS Rule");
|
|
2318
|
-
const ruleScanBody = (placeholderRuleSection ?? "").toLowerCase();
|
|
2319
|
-
const ruleAcceptedHits = ruleScanBody.length > 0
|
|
2320
|
-
? allPlaceholderTokens.filter((token) => ruleScanBody.includes(token))
|
|
2321
|
-
: [];
|
|
2322
|
-
const filteredPlanHits = planWidePlaceholderHits.filter((token) => {
|
|
2323
|
-
// If the only occurrence is in the rule section, ignore it.
|
|
2324
|
-
if (!ruleAcceptedHits.includes(token))
|
|
2325
|
-
return true;
|
|
2326
|
-
const occurrencesElsewhere = lowerRaw.split(token).length - 1
|
|
2327
|
-
- (ruleScanBody.split(token).length - 1);
|
|
2328
|
-
return occurrencesElsewhere > 0;
|
|
2329
|
-
});
|
|
2330
|
-
findings.push({
|
|
2331
|
-
section: "Plan-wide Placeholder Scan",
|
|
2332
|
-
required: false,
|
|
2333
|
-
rule: "Plan should not contain forbidden placeholder tokens outside the NO PLACEHOLDERS rule section.",
|
|
2334
|
-
found: filteredPlanHits.length === 0,
|
|
2335
|
-
details: filteredPlanHits.length === 0
|
|
2336
|
-
? "No forbidden placeholder tokens detected outside the rule section."
|
|
2337
|
-
: `Detected forbidden token(s) elsewhere in plan: ${filteredPlanHits.join(", ")}.`
|
|
2338
|
-
});
|
|
2339
|
-
const handoffBody = sectionBodyByName(sections, "Execution Handoff");
|
|
2340
|
-
if (handoffBody !== null) {
|
|
2341
|
-
const ok = /(subagent-driven|inline executor)/iu.test(handoffBody);
|
|
2342
|
-
findings.push({
|
|
2343
|
-
section: "Execution Handoff Posture",
|
|
2344
|
-
required: true,
|
|
2345
|
-
rule: "Execution Handoff must declare a posture (Subagent-Driven or Inline executor).",
|
|
2346
|
-
found: ok,
|
|
2347
|
-
details: ok
|
|
2348
|
-
? "Execution Handoff posture declared."
|
|
2349
|
-
: "Execution Handoff is missing a posture declaration (Subagent-Driven or Inline executor)."
|
|
2350
|
-
});
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
if (stage === "scope") {
|
|
2354
|
-
const lockedDecisionsBody = sectionBodyByHeadingPrefix(sections, "Locked Decisions") ?? "";
|
|
2355
|
-
const strictScopeGuards = parsedFrontmatter.hasFrontmatter ||
|
|
2356
|
-
sectionBodyByHeadingPrefix(sections, "Locked Decisions") !== null;
|
|
2357
|
-
const scopeSections = [
|
|
2358
|
-
sectionBodyByAnyName(sections, ["In Scope / Out of Scope", "In Scope", "Out of Scope"]) ?? "",
|
|
2359
|
-
sectionBodyByName(sections, "Scope Summary") ?? "",
|
|
2360
|
-
lockedDecisionsBody
|
|
2361
|
-
].join("\n");
|
|
2362
|
-
const reductionHits = collectPatternHits(scopeSections, SCOPE_REDUCTION_PATTERNS);
|
|
2363
|
-
findings.push({
|
|
2364
|
-
section: "No Scope Reduction Language",
|
|
2365
|
-
required: strictScopeGuards,
|
|
2366
|
-
rule: "Scope boundary sections must not use reduction placeholders (`v1`, `for now`, `later`, `temporary`, `placeholder`).",
|
|
2367
|
-
found: reductionHits.length === 0,
|
|
2368
|
-
details: reductionHits.length === 0
|
|
2369
|
-
? "No scope-reduction phrases detected in scope boundary sections."
|
|
2370
|
-
: `Detected scope-reduction phrase(s): ${reductionHits.join(", ")}.`
|
|
2371
|
-
});
|
|
2372
|
-
if (sectionBodyByHeadingPrefix(sections, "Locked Decisions") !== null) {
|
|
2373
|
-
const anchorValidation = validateLockedDecisionAnchors(lockedDecisionsBody);
|
|
2374
|
-
findings.push({
|
|
2375
|
-
section: "Locked Decisions Hash Integrity",
|
|
2376
|
-
required: true,
|
|
2377
|
-
rule: "Locked Decisions section must list unique LD#<sha8> content-derived anchors.",
|
|
2378
|
-
found: anchorValidation.ok,
|
|
2379
|
-
details: anchorValidation.details
|
|
2380
|
-
});
|
|
2381
|
-
// Legacy D-XX rows remain advisory for older artifacts, but new templates
|
|
2382
|
-
// use LD#hash anchors. This check keeps D-XX duplicates visible without
|
|
2383
|
-
// making old artifacts the primary contract.
|
|
2384
|
-
const listDecisionLines = lockedDecisionsBody
|
|
2385
|
-
.split(/\r?\n/u)
|
|
2386
|
-
.map((line) => line.trim())
|
|
2387
|
-
.filter((line) => /^[-*]\s+\S/u.test(line));
|
|
2388
|
-
const tableDecisionRows = getMarkdownTableRows(lockedDecisionsBody);
|
|
2389
|
-
const tableDecisionLines = tableDecisionRows.map((row) => row.join(" | "));
|
|
2390
|
-
const decisionLines = [...listDecisionLines, ...tableDecisionLines];
|
|
2391
|
-
const orphanDecisionLines = decisionLines.filter((line) => !/\bD-\d+\b/u.test(line));
|
|
2392
|
-
const rowDecisionIds = [
|
|
2393
|
-
...listDecisionLines.map((line) => /\bD-\d+\b/u.exec(line)?.[0]),
|
|
2394
|
-
...tableDecisionRows.map((row) => /\bD-\d+\b/u.exec(row[0] ?? "")?.[0])
|
|
2395
|
-
].filter((id) => typeof id === "string");
|
|
2396
|
-
const duplicateIds = (() => {
|
|
2397
|
-
const counts = new Map();
|
|
2398
|
-
for (const id of rowDecisionIds)
|
|
2399
|
-
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
2400
|
-
return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
|
|
2401
|
-
})();
|
|
2402
|
-
const issues = [];
|
|
2403
|
-
if (rowDecisionIds.length === 0 && decisionLines.length === 0) {
|
|
2404
|
-
issues.push("section is empty");
|
|
2405
|
-
}
|
|
2406
|
-
if (orphanDecisionLines.length > 0) {
|
|
2407
|
-
const examples = orphanDecisionLines
|
|
2408
|
-
.slice(0, 3)
|
|
2409
|
-
.map((line) => `\`${line.slice(0, 120)}\``)
|
|
2410
|
-
.join(", ");
|
|
2411
|
-
issues.push(`${orphanDecisionLines.length} decision row(s) missing a D-XX ID${examples.length > 0 ? `: ${examples}` : ""}`);
|
|
2412
|
-
}
|
|
2413
|
-
if (duplicateIds.length > 0) {
|
|
2414
|
-
issues.push(`duplicate IDs: ${duplicateIds.join(", ")}`);
|
|
2415
|
-
}
|
|
2416
|
-
findings.push({
|
|
2417
|
-
section: "Locked Decisions ID Integrity",
|
|
2418
|
-
required: false,
|
|
2419
|
-
rule: "Locked Decisions section must list each decision with a unique stable D-XX ID.",
|
|
2420
|
-
found: issues.length === 0,
|
|
2421
|
-
details: issues.length === 0
|
|
2422
|
-
? `${rowDecisionIds.length} decision ID(s) recorded with no duplicates.`
|
|
2423
|
-
: issues.join("; ")
|
|
2424
|
-
});
|
|
2425
|
-
}
|
|
2426
|
-
// Universal Layer 2.2 structural checks (gstack plan-ceo-review). All
|
|
2427
|
-
// present-only — they validate shape when the section exists.
|
|
2428
|
-
const altsBody = sectionBodyByName(sections, "Implementation Alternatives");
|
|
2429
|
-
if (altsBody !== null) {
|
|
2430
|
-
const recommendation = /^RECOMMENDATION:\s*(.+)$/imu.test(altsBody);
|
|
2431
|
-
findings.push({
|
|
2432
|
-
section: "Implementation Alternatives Recommendation",
|
|
2433
|
-
required: true,
|
|
2434
|
-
rule: "Implementation Alternatives must conclude with a `RECOMMENDATION:` line citing the chosen option and rationale.",
|
|
2435
|
-
found: recommendation,
|
|
2436
|
-
details: recommendation
|
|
2437
|
-
? "Recommendation marker present."
|
|
2438
|
-
: "Missing or empty `RECOMMENDATION:` line under Implementation Alternatives."
|
|
2439
|
-
});
|
|
2440
|
-
}
|
|
2441
|
-
const failureModesBody = sectionBodyByName(sections, "Failure Modes Registry");
|
|
2442
|
-
if (failureModesBody !== null) {
|
|
2443
|
-
const required = ["Codepath", "Failure mode", "Rescued?", "Test?", "User sees?", "Logged?"];
|
|
2444
|
-
const headerOk = required.every((column) => failureModesBody.includes(column));
|
|
2445
|
-
const rows = failureModesBody.split("\n").filter((line) => /^\|/u.test(line));
|
|
2446
|
-
const hasDataRow = rows.length >= 3 && rows.slice(2).some((row) => row
|
|
2447
|
-
.split("|")
|
|
2448
|
-
.slice(1, -1)
|
|
2449
|
-
.some((cell) => cell.trim().length > 0));
|
|
2450
|
-
findings.push({
|
|
2451
|
-
section: "Failure Modes Registry Shape",
|
|
2452
|
-
required: true,
|
|
2453
|
-
rule: "Failure Modes Registry must include columns Codepath / Failure mode / Rescued? / Test? / User sees? / Logged? and at least one populated data row.",
|
|
2454
|
-
found: headerOk && hasDataRow,
|
|
2455
|
-
details: !headerOk
|
|
2456
|
-
? "Failure Modes Registry header is missing one or more required columns."
|
|
2457
|
-
: hasDataRow
|
|
2458
|
-
? "Failure Modes Registry header and at least one data row present."
|
|
2459
|
-
: "Failure Modes Registry has the canonical header but no populated data row."
|
|
2460
|
-
});
|
|
2461
|
-
const decisionMarkers = (failureModesBody.match(/decision\s*:/giu) ?? []).length;
|
|
2462
|
-
findings.push({
|
|
2463
|
-
section: "Failure Modes STOP-per-issue",
|
|
2464
|
-
required: true,
|
|
2465
|
-
rule: "Each Failure Modes Registry data row must record a `decision:` marker (STOP-per-issue protocol).",
|
|
2466
|
-
found: decisionMarkers >= 1,
|
|
2467
|
-
details: decisionMarkers >= 1
|
|
2468
|
-
? `Detected ${decisionMarkers} decision marker(s).`
|
|
2469
|
-
: "Failure Modes Registry has no `decision:` markers; STOP-per-issue requires at least one."
|
|
2470
|
-
});
|
|
2471
|
-
}
|
|
2472
|
-
const reversibilityBody = sectionBodyByName(sections, "Reversibility Rating");
|
|
2473
|
-
if (reversibilityBody !== null) {
|
|
2474
|
-
const scoreMatch = reversibilityBody.match(/score\s*\(.*?\)\s*:\s*([1-5])\b/iu);
|
|
2475
|
-
const ok = scoreMatch !== null;
|
|
2476
|
-
findings.push({
|
|
2477
|
-
section: "Reversibility Rating Score",
|
|
2478
|
-
required: true,
|
|
2479
|
-
rule: "Reversibility Rating must declare a score in the range 1-5.",
|
|
2480
|
-
found: ok,
|
|
2481
|
-
details: ok
|
|
2482
|
-
? `Reversibility score ${scoreMatch[1]} declared.`
|
|
2483
|
-
: "Reversibility Rating is missing a numeric score 1-5."
|
|
2484
|
-
});
|
|
2485
|
-
}
|
|
2486
|
-
}
|
|
2487
|
-
if (stage === "spec") {
|
|
2488
|
-
// Universal Layer 2.4 structural checks (evanflow-prd + superpowers).
|
|
2489
|
-
// All checks fire only when the matching section is present so legacy
|
|
2490
|
-
// fixtures keep working while v3-template artifacts are validated.
|
|
2491
|
-
const synthesisBody = sectionBodyByName(sections, "Synthesis Sources");
|
|
2492
|
-
if (synthesisBody !== null) {
|
|
2493
|
-
const tableRows = synthesisBody
|
|
2494
|
-
.split("\n")
|
|
2495
|
-
.filter((line) => /^\|/u.test(line));
|
|
2496
|
-
const dataRows = tableRows.length >= 3 ? tableRows.slice(2) : [];
|
|
2497
|
-
const populatedRows = dataRows.filter((row) => row
|
|
2498
|
-
.split("|")
|
|
2499
|
-
.slice(1, -1)
|
|
2500
|
-
.some((cell) => cell.trim().length > 0));
|
|
2501
|
-
const hasRow = populatedRows.length >= 1;
|
|
2502
|
-
findings.push({
|
|
2503
|
-
section: "Synthesis Sources Coverage",
|
|
2504
|
-
required: true,
|
|
2505
|
-
rule: "Synthesis Sources must cite at least one source artifact (synthesize-not-interview).",
|
|
2506
|
-
found: hasRow,
|
|
2507
|
-
details: hasRow
|
|
2508
|
-
? `Detected ${populatedRows.length} populated source row(s).`
|
|
2509
|
-
: "Synthesis Sources is empty; spec must cite at least one upstream artifact or context file."
|
|
2510
|
-
});
|
|
2511
|
-
}
|
|
2512
|
-
const behaviorBody = sectionBodyByName(sections, "Behavior Contract");
|
|
2513
|
-
if (behaviorBody !== null) {
|
|
2514
|
-
const optedOut = /(^|\n)\s*-\s*None\b/iu.test(behaviorBody);
|
|
2515
|
-
const userStoryRegex = /(^|\n)\s*-\s*as\s+a\b[\s\S]*?,\s*i\s+can\b[\s\S]*?,\s*so that\b/imu;
|
|
2516
|
-
const givenWhenThenRegex = /(^|\n)\s*-\s*given\b[\s\S]*?,\s*when\b[\s\S]*?,\s*then\b/imu;
|
|
2517
|
-
const matches = [
|
|
2518
|
-
...behaviorBody.matchAll(/(^|\n)\s*-\s*as\s+a\b[\s\S]*?,\s*i\s+can\b[\s\S]*?,\s*so that\b/gimu),
|
|
2519
|
-
...behaviorBody.matchAll(/(^|\n)\s*-\s*given\b[\s\S]*?,\s*when\b[\s\S]*?,\s*then\b/gimu)
|
|
2520
|
-
];
|
|
2521
|
-
const ok = optedOut || matches.length >= 3;
|
|
2522
|
-
findings.push({
|
|
2523
|
-
section: "Behavior Contract Shape",
|
|
2524
|
-
required: true,
|
|
2525
|
-
rule: "Behavior Contract must list ≥3 behaviors in user-story (As a/I can/so that) or Given/When/Then form, or declare `- None.` for single-step specs.",
|
|
2526
|
-
found: ok,
|
|
2527
|
-
details: optedOut
|
|
2528
|
-
? "Single-step spec; behaviors opted out via `- None.`."
|
|
2529
|
-
: ok
|
|
2530
|
-
? `Detected ${matches.length} behavior(s) in canonical form.`
|
|
2531
|
-
: `Detected ${matches.length} behavior(s) in canonical form; need ≥3 (or `
|
|
2532
|
-
+ "`- None.`).",
|
|
2533
|
-
});
|
|
2534
|
-
// Bonus: detect if at least one user-story OR given/when/then form is present
|
|
2535
|
-
// (mirrors existing helpers).
|
|
2536
|
-
void userStoryRegex;
|
|
2537
|
-
void givenWhenThenRegex;
|
|
2538
|
-
}
|
|
2539
|
-
const archModulesBody = sectionBodyByName(sections, "Architecture Modules");
|
|
2540
|
-
if (archModulesBody !== null) {
|
|
2541
|
-
const codeFenceCount = (archModulesBody.match(/```/gu) ?? []).length;
|
|
2542
|
-
const fnSignatureRegex = /\b(function|class|def|fn|method)\b\s+[A-Za-z_]/u;
|
|
2543
|
-
const noCode = codeFenceCount === 0 && !fnSignatureRegex.test(archModulesBody);
|
|
2544
|
-
findings.push({
|
|
2545
|
-
section: "Architecture Modules No-Code",
|
|
2546
|
-
required: true,
|
|
2547
|
-
rule: "Architecture Modules must not contain code blocks, function signatures, or class definitions — modules listed by responsibility only.",
|
|
2548
|
-
found: noCode,
|
|
2549
|
-
details: noCode
|
|
2550
|
-
? "Architecture Modules is free of code blocks and function/class signatures."
|
|
2551
|
-
: "Architecture Modules contains a code fence or function/class signature; remove code-level details."
|
|
2552
|
-
});
|
|
2553
|
-
}
|
|
2554
|
-
const selfReviewBody = sectionBodyByName(sections, "Spec Self-Review");
|
|
2555
|
-
if (selfReviewBody !== null) {
|
|
2556
|
-
const required = ["placeholder", "consistency", "scope", "ambiguity"];
|
|
2557
|
-
const missing = required.filter((token) => !new RegExp(token, "iu").test(selfReviewBody));
|
|
2558
|
-
findings.push({
|
|
2559
|
-
section: "Spec Self-Review Coverage",
|
|
2560
|
-
required: true,
|
|
2561
|
-
rule: "Spec Self-Review must cover placeholder/consistency/scope/ambiguity checks.",
|
|
2562
|
-
found: missing.length === 0,
|
|
2563
|
-
details: missing.length === 0
|
|
2564
|
-
? "Spec Self-Review covers all required checks."
|
|
2565
|
-
: `Spec Self-Review is missing check(s): ${missing.join(", ")}.`
|
|
2566
|
-
});
|
|
2567
|
-
}
|
|
2568
|
-
}
|
|
2569
|
-
if (stage === "tdd") {
|
|
2570
|
-
// Universal Layer 2.6 structural checks (superpowers TDD + evanflow vertical slices).
|
|
2571
|
-
const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
|
|
2572
|
-
if (ironLawBody !== null) {
|
|
2573
|
-
const ack = /acknowledged:\s*(yes|true|y)\b/iu.test(ironLawBody);
|
|
2574
|
-
findings.push({
|
|
2575
|
-
section: "TDD Iron Law Acknowledgement",
|
|
2576
|
-
required: true,
|
|
2577
|
-
rule: "Iron Law Acknowledgement must affirm `Acknowledged: yes`.",
|
|
2578
|
-
found: ack,
|
|
2579
|
-
details: ack
|
|
2580
|
-
? "TDD Iron Law acknowledged."
|
|
2581
|
-
: "Iron Law Acknowledgement is missing explicit `Acknowledged: yes`."
|
|
2582
|
-
});
|
|
2583
|
-
}
|
|
2584
|
-
const watchedRedBody = sectionBodyByName(sections, "Watched-RED Proof");
|
|
2585
|
-
if (watchedRedBody !== null) {
|
|
2586
|
-
const rows = watchedRedBody.split("\n").filter((line) => /^\|/u.test(line));
|
|
2587
|
-
const dataRows = rows.length >= 3 ? rows.slice(2) : [];
|
|
2588
|
-
const populatedRows = dataRows.filter((row) => row
|
|
2589
|
-
.split("|")
|
|
2590
|
-
.slice(1, -1)
|
|
2591
|
-
.filter((_, idx) => idx !== 0) // skip slice column
|
|
2592
|
-
.some((cell) => cell.trim().length > 0));
|
|
2593
|
-
// Each populated row must include an ISO timestamp in column 3.
|
|
2594
|
-
const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
|
|
2595
|
-
const validProofRows = populatedRows.filter((row) => isoRegex.test(row));
|
|
2596
|
-
findings.push({
|
|
2597
|
-
section: "Watched-RED Proof Shape",
|
|
2598
|
-
required: true,
|
|
2599
|
-
rule: "Watched-RED Proof rows must include an ISO timestamp showing when the test was observed failing.",
|
|
2600
|
-
found: populatedRows.length === 0 || validProofRows.length === populatedRows.length,
|
|
2601
|
-
details: populatedRows.length === 0
|
|
2602
|
-
? "Watched-RED Proof has no populated rows."
|
|
2603
|
-
: validProofRows.length === populatedRows.length
|
|
2604
|
-
? `All ${populatedRows.length} watched-RED proof row(s) include an ISO timestamp.`
|
|
2605
|
-
: `${populatedRows.length - validProofRows.length} watched-RED proof row(s) lack an ISO timestamp.`
|
|
2606
|
-
});
|
|
2607
|
-
}
|
|
2608
|
-
const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
|
|
2609
|
-
if (sliceCycleBody !== null) {
|
|
2610
|
-
const required = ["RED", "GREEN", "REFACTOR"];
|
|
2611
|
-
const missing = required.filter((token) => !new RegExp(token, "u").test(sliceCycleBody));
|
|
2612
|
-
findings.push({
|
|
2613
|
-
section: "Vertical Slice Cycle Coverage",
|
|
2614
|
-
required: true,
|
|
2615
|
-
rule: "Vertical Slice Cycle must include RED, GREEN, and REFACTOR per slice (refactor may be deferred with rationale).",
|
|
2616
|
-
found: missing.length === 0,
|
|
2617
|
-
details: missing.length === 0
|
|
2618
|
-
? "Vertical Slice Cycle references RED/GREEN/REFACTOR."
|
|
2619
|
-
: `Vertical Slice Cycle is missing phase token(s): ${missing.join(", ")}.`
|
|
2620
|
-
});
|
|
2621
|
-
}
|
|
2622
|
-
const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
|
|
2623
|
-
if (assertionBody !== null) {
|
|
2624
|
-
const tableRows = assertionBody.split("\n").filter((line) => /^\|/u.test(line));
|
|
2625
|
-
const dataRows = tableRows.length >= 3 ? tableRows.slice(2) : [];
|
|
2626
|
-
const ok = dataRows.length === 0 || dataRows.some((row) => row
|
|
2627
|
-
.split("|")
|
|
2628
|
-
.slice(1, -1)
|
|
2629
|
-
.some((cell) => cell.trim().length > 0));
|
|
2630
|
-
findings.push({
|
|
2631
|
-
section: "Assertion Correctness Notes Shape",
|
|
2632
|
-
required: true,
|
|
2633
|
-
rule: "Assertion Correctness Notes must include at least one populated row when the slice has new assertions.",
|
|
2634
|
-
found: ok,
|
|
2635
|
-
details: ok
|
|
2636
|
-
? "Assertion Correctness Notes is populated or absent (single-step slice)."
|
|
2637
|
-
: "Assertion Correctness Notes table has no populated rows."
|
|
2638
|
-
});
|
|
2639
|
-
}
|
|
2640
|
-
}
|
|
2641
|
-
if (stage === "review") {
|
|
2642
|
-
// Universal Layer 2.7 structural checks (superpowers requesting + receiving).
|
|
2643
|
-
const frameBody = sectionBodyByName(sections, "Frame the Review Request");
|
|
2644
|
-
if (frameBody !== null) {
|
|
2645
|
-
const required = ["Goal:", "Approach:", "Risk areas:", "Verification done:", "Open questions"];
|
|
2646
|
-
const missing = required.filter((token) => !new RegExp(token.replace(":", "\\s*:"), "iu").test(frameBody));
|
|
2647
|
-
findings.push({
|
|
2648
|
-
section: "Review Frame Coverage",
|
|
2649
|
-
required: true,
|
|
2650
|
-
rule: "Frame the Review Request must include Goal, Approach, Risk areas, Verification done, Open questions.",
|
|
2651
|
-
found: missing.length === 0,
|
|
2652
|
-
details: missing.length === 0
|
|
2653
|
-
? "Review request frame covers all required fields."
|
|
2654
|
-
: `Frame is missing field(s): ${missing.join(", ")}.`
|
|
2655
|
-
});
|
|
2656
|
-
}
|
|
2657
|
-
const criticBody = sectionBodyByName(sections, "Critic Subagent Dispatch");
|
|
2658
|
-
if (criticBody !== null) {
|
|
2659
|
-
const required = [
|
|
2660
|
-
"Critic agent definition path",
|
|
2661
|
-
"Dispatch surface",
|
|
2662
|
-
"Frame sent",
|
|
2663
|
-
"Critic returned"
|
|
2664
|
-
];
|
|
2665
|
-
const missing = required.filter((token) => !criticBody.includes(token));
|
|
2666
|
-
findings.push({
|
|
2667
|
-
section: "Critic Subagent Dispatch Shape",
|
|
2668
|
-
required: true,
|
|
2669
|
-
rule: "Critic Subagent Dispatch must declare agent definition path, dispatch surface, frame sent, and critic-returned summary.",
|
|
2670
|
-
found: missing.length === 0,
|
|
2671
|
-
details: missing.length === 0
|
|
2672
|
-
? "Critic dispatch metadata complete."
|
|
2673
|
-
: `Critic Subagent Dispatch is missing field(s): ${missing.join(", ")}.`
|
|
2674
|
-
});
|
|
2675
|
-
}
|
|
2676
|
-
const receivingBody = sectionBodyByName(sections, "Receiving Posture");
|
|
2677
|
-
if (receivingBody !== null) {
|
|
2678
|
-
const ack = /no performative agreement/iu.test(receivingBody);
|
|
2679
|
-
findings.push({
|
|
2680
|
-
section: "Receiving Posture Anti-Sycophancy",
|
|
2681
|
-
required: true,
|
|
2682
|
-
rule: "Receiving Posture must affirm `No performative agreement (forbidden openers acknowledged)`.",
|
|
2683
|
-
found: ack,
|
|
2684
|
-
details: ack
|
|
2685
|
-
? "Receiving posture acknowledged anti-sycophancy."
|
|
2686
|
-
: "Receiving Posture is missing the anti-sycophancy acknowledgement line."
|
|
2687
|
-
});
|
|
2688
|
-
}
|
|
2689
|
-
}
|
|
2690
|
-
if (stage === "ship") {
|
|
2691
|
-
// Universal Layer 2.8 structural checks (superpowers finishing-a-development-branch).
|
|
2692
|
-
const optionsBody = sectionBodyByName(sections, "Finalization Options");
|
|
2693
|
-
if (optionsBody !== null) {
|
|
2694
|
-
const required = ["MERGE_LOCAL", "OPEN_PR", "KEEP_BRANCH", "DISCARD"];
|
|
2695
|
-
const missing = required.filter((token) => !optionsBody.includes(token));
|
|
2696
|
-
findings.push({
|
|
2697
|
-
section: "Finalization Options Coverage",
|
|
2698
|
-
required: true,
|
|
2699
|
-
rule: "Finalization Options must surface all four canonical options (MERGE_LOCAL, OPEN_PR, KEEP_BRANCH, DISCARD).",
|
|
2700
|
-
found: missing.length === 0,
|
|
2701
|
-
details: missing.length === 0
|
|
2702
|
-
? "All four finalization options surfaced."
|
|
2703
|
-
: `Finalization Options is missing token(s): ${missing.join(", ")}.`
|
|
2704
|
-
});
|
|
2705
|
-
}
|
|
2706
|
-
const prBody = sectionBodyByName(sections, "Structured PR Body");
|
|
2707
|
-
if (prBody !== null) {
|
|
2708
|
-
const required = ["## Summary", "## Test Plan", "## Commits Included"];
|
|
2709
|
-
const missing = required.filter((token) => !prBody.includes(token));
|
|
2710
|
-
findings.push({
|
|
2711
|
-
section: "Structured PR Body Shape",
|
|
2712
|
-
required: true,
|
|
2713
|
-
rule: "Structured PR Body must include `## Summary`, `## Test Plan`, and `## Commits Included` subsections.",
|
|
2714
|
-
found: missing.length === 0,
|
|
2715
|
-
details: missing.length === 0
|
|
2716
|
-
? "Structured PR Body covers all required subsections."
|
|
2717
|
-
: `Structured PR Body is missing subsection(s): ${missing.join(", ")}.`
|
|
2718
|
-
});
|
|
2719
|
-
}
|
|
2720
|
-
const verifyBody = sectionBodyByName(sections, "Verify Tests Gate");
|
|
2721
|
-
if (verifyBody !== null) {
|
|
2722
|
-
const ok = /\bResult:\s*(PASS|FAIL)\b/iu.test(verifyBody);
|
|
2723
|
-
findings.push({
|
|
2724
|
-
section: "Verify Tests Gate Result",
|
|
2725
|
-
required: true,
|
|
2726
|
-
rule: "Verify Tests Gate must declare a Result of PASS or FAIL.",
|
|
2727
|
-
found: ok,
|
|
2728
|
-
details: ok
|
|
2729
|
-
? "Verify Tests Gate result declared."
|
|
2730
|
-
: "Verify Tests Gate is missing a `Result: PASS|FAIL` line."
|
|
2731
|
-
});
|
|
2732
|
-
}
|
|
152
|
+
const stageContext = {
|
|
153
|
+
projectRoot,
|
|
154
|
+
stage,
|
|
155
|
+
track,
|
|
156
|
+
raw,
|
|
157
|
+
absFile,
|
|
158
|
+
sections,
|
|
159
|
+
findings,
|
|
160
|
+
parsedFrontmatter,
|
|
161
|
+
brainstormShortCircuitBody,
|
|
162
|
+
brainstormShortCircuitActivated,
|
|
163
|
+
scopePreAuditEnabled,
|
|
164
|
+
staleDiagramAuditEnabled,
|
|
165
|
+
isTrivialOverride,
|
|
166
|
+
overrideSet
|
|
167
|
+
};
|
|
168
|
+
switch (stage) {
|
|
169
|
+
case "brainstorm":
|
|
170
|
+
await lintBrainstormStage(stageContext);
|
|
171
|
+
break;
|
|
172
|
+
case "design":
|
|
173
|
+
await lintDesignStage(stageContext);
|
|
174
|
+
break;
|
|
175
|
+
case "plan":
|
|
176
|
+
await lintPlanStage(stageContext);
|
|
177
|
+
break;
|
|
178
|
+
case "scope":
|
|
179
|
+
await lintScopeStage(stageContext);
|
|
180
|
+
break;
|
|
181
|
+
case "spec":
|
|
182
|
+
await lintSpecStage(stageContext);
|
|
183
|
+
break;
|
|
184
|
+
case "tdd":
|
|
185
|
+
await lintTddStage(stageContext);
|
|
186
|
+
break;
|
|
187
|
+
case "review":
|
|
188
|
+
await lintReviewStage(stageContext);
|
|
189
|
+
break;
|
|
190
|
+
case "ship":
|
|
191
|
+
await lintShipStage(stageContext);
|
|
192
|
+
break;
|
|
193
|
+
default:
|
|
194
|
+
break;
|
|
2733
195
|
}
|
|
2734
196
|
if (["design", "spec", "plan", "review"].includes(stage)) {
|
|
2735
197
|
const scopeArtifact = await resolveStageArtifactPath("scope", {
|
|
@@ -2774,358 +236,3 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
|
|
|
2774
236
|
const passed = findings.every((f) => !f.required || f.found);
|
|
2775
237
|
return { stage, file: relFile, passed, findings };
|
|
2776
238
|
}
|
|
2777
|
-
function isNonEmptyString(v) {
|
|
2778
|
-
return typeof v === "string" && v.length > 0;
|
|
2779
|
-
}
|
|
2780
|
-
function isFiniteNumber(v) {
|
|
2781
|
-
return typeof v === "number" && Number.isFinite(v);
|
|
2782
|
-
}
|
|
2783
|
-
function isNonNegativeInteger(v) {
|
|
2784
|
-
return Number.isInteger(v) && v >= 0;
|
|
2785
|
-
}
|
|
2786
|
-
function isStringArray(v) {
|
|
2787
|
-
return Array.isArray(v) && v.every((item) => typeof item === "string");
|
|
2788
|
-
}
|
|
2789
|
-
export async function validateReviewArmy(projectRoot) {
|
|
2790
|
-
const errors = [];
|
|
2791
|
-
const { absPath, relPath } = await resolveNamedArtifactPath(projectRoot, "07-review-army.json");
|
|
2792
|
-
if (!(await exists(absPath))) {
|
|
2793
|
-
return { valid: false, errors: [`Missing file: ${relPath}`] };
|
|
2794
|
-
}
|
|
2795
|
-
let parsed;
|
|
2796
|
-
try {
|
|
2797
|
-
parsed = JSON.parse(await fs.readFile(absPath, "utf8"));
|
|
2798
|
-
}
|
|
2799
|
-
catch (e) {
|
|
2800
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2801
|
-
return { valid: false, errors: [`Invalid JSON: ${msg}`] };
|
|
2802
|
-
}
|
|
2803
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2804
|
-
return { valid: false, errors: ["Root value must be a JSON object."] };
|
|
2805
|
-
}
|
|
2806
|
-
const root = parsed;
|
|
2807
|
-
if (!("version" in root) || !isFiniteNumber(root.version) || root.version < 1) {
|
|
2808
|
-
errors.push('Field "version" must be a finite number >= 1.');
|
|
2809
|
-
}
|
|
2810
|
-
if (!isNonEmptyString(root.generatedAt)) {
|
|
2811
|
-
errors.push('Field "generatedAt" must be a non-empty string.');
|
|
2812
|
-
}
|
|
2813
|
-
if (!("scope" in root) || root.scope === null || typeof root.scope !== "object" || Array.isArray(root.scope)) {
|
|
2814
|
-
errors.push('Field "scope" must be an object.');
|
|
2815
|
-
}
|
|
2816
|
-
else {
|
|
2817
|
-
const scope = root.scope;
|
|
2818
|
-
if (!isNonEmptyString(scope.base)) {
|
|
2819
|
-
errors.push("scope.base must be a non-empty string.");
|
|
2820
|
-
}
|
|
2821
|
-
if (!isNonEmptyString(scope.head)) {
|
|
2822
|
-
errors.push("scope.head must be a non-empty string.");
|
|
2823
|
-
}
|
|
2824
|
-
if (!isStringArray(scope.files)) {
|
|
2825
|
-
errors.push("scope.files must be an array of strings.");
|
|
2826
|
-
}
|
|
2827
|
-
}
|
|
2828
|
-
const severitySet = new Set(["Critical", "Important", "Suggestion"]);
|
|
2829
|
-
const statusSet = new Set(["open", "accepted", "resolved"]);
|
|
2830
|
-
const sourceSet = new Set([
|
|
2831
|
-
"spec",
|
|
2832
|
-
"correctness",
|
|
2833
|
-
"security",
|
|
2834
|
-
"performance",
|
|
2835
|
-
"architecture",
|
|
2836
|
-
"external-safety"
|
|
2837
|
-
]);
|
|
2838
|
-
const findingIds = new Set();
|
|
2839
|
-
const openCriticalIds = new Set();
|
|
2840
|
-
if (!Array.isArray(root.findings)) {
|
|
2841
|
-
errors.push('Field "findings" must be an array.');
|
|
2842
|
-
}
|
|
2843
|
-
else {
|
|
2844
|
-
root.findings.forEach((f, i) => {
|
|
2845
|
-
if (f === null || typeof f !== "object" || Array.isArray(f)) {
|
|
2846
|
-
errors.push(`findings[${i}] must be an object.`);
|
|
2847
|
-
return;
|
|
2848
|
-
}
|
|
2849
|
-
const o = f;
|
|
2850
|
-
if (!isNonEmptyString(o.id)) {
|
|
2851
|
-
errors.push(`findings[${i}].id must be a non-empty string.`);
|
|
2852
|
-
}
|
|
2853
|
-
else if (findingIds.has(o.id)) {
|
|
2854
|
-
errors.push(`findings[${i}].id must be unique.`);
|
|
2855
|
-
}
|
|
2856
|
-
else {
|
|
2857
|
-
findingIds.add(o.id);
|
|
2858
|
-
}
|
|
2859
|
-
if (!isNonEmptyString(o.severity) || !severitySet.has(o.severity)) {
|
|
2860
|
-
errors.push(`findings[${i}].severity must be one of: Critical, Important, Suggestion.`);
|
|
2861
|
-
}
|
|
2862
|
-
if (!isNonEmptyString(o.status) || !statusSet.has(o.status)) {
|
|
2863
|
-
errors.push(`findings[${i}].status must be one of: open, accepted, resolved.`);
|
|
2864
|
-
}
|
|
2865
|
-
if (!isNonEmptyString(o.fingerprint)) {
|
|
2866
|
-
errors.push(`findings[${i}].fingerprint must be a non-empty string.`);
|
|
2867
|
-
}
|
|
2868
|
-
if (!isFiniteNumber(o.confidence) || o.confidence < 1 || o.confidence > 10) {
|
|
2869
|
-
errors.push(`findings[${i}].confidence must be a number in [1,10].`);
|
|
2870
|
-
}
|
|
2871
|
-
if (!isStringArray(o.reportedBy) || o.reportedBy.length === 0) {
|
|
2872
|
-
errors.push(`findings[${i}].reportedBy must be a non-empty string array.`);
|
|
2873
|
-
}
|
|
2874
|
-
if (o.sources !== undefined) {
|
|
2875
|
-
if (!isStringArray(o.sources) || o.sources.length === 0) {
|
|
2876
|
-
errors.push(`findings[${i}].sources must be a non-empty string array when present.`);
|
|
2877
|
-
}
|
|
2878
|
-
else {
|
|
2879
|
-
const invalidSources = o.sources.filter((source) => !sourceSet.has(source));
|
|
2880
|
-
if (invalidSources.length > 0) {
|
|
2881
|
-
errors.push(`findings[${i}].sources contains unknown values: ${invalidSources.join(", ")}.`);
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2884
|
-
}
|
|
2885
|
-
if (o.location === undefined || o.location === null) {
|
|
2886
|
-
errors.push(`findings[${i}].location is required and must be an object with file + line.`);
|
|
2887
|
-
}
|
|
2888
|
-
else if (typeof o.location !== "object" || Array.isArray(o.location)) {
|
|
2889
|
-
errors.push(`findings[${i}].location must be an object with file + line.`);
|
|
2890
|
-
}
|
|
2891
|
-
else {
|
|
2892
|
-
const loc = o.location;
|
|
2893
|
-
if (!isNonEmptyString(loc.file)) {
|
|
2894
|
-
errors.push(`findings[${i}].location.file must be a non-empty string.`);
|
|
2895
|
-
}
|
|
2896
|
-
if (!isFiniteNumber(loc.line) || loc.line < 1) {
|
|
2897
|
-
errors.push(`findings[${i}].location.line must be a positive number.`);
|
|
2898
|
-
}
|
|
2899
|
-
}
|
|
2900
|
-
if (o.recommendation !== undefined && !isNonEmptyString(o.recommendation)) {
|
|
2901
|
-
errors.push(`findings[${i}].recommendation must be a non-empty string when present.`);
|
|
2902
|
-
}
|
|
2903
|
-
if (o.severity === "Critical" && o.status === "open" && !isNonEmptyString(o.recommendation)) {
|
|
2904
|
-
errors.push(`findings[${i}] open Critical finding must include recommendation.`);
|
|
2905
|
-
}
|
|
2906
|
-
if (o.id && o.severity === "Critical" && o.status === "open" && typeof o.id === "string") {
|
|
2907
|
-
openCriticalIds.add(o.id);
|
|
2908
|
-
}
|
|
2909
|
-
});
|
|
2910
|
-
}
|
|
2911
|
-
if (!("reconciliation" in root) || root.reconciliation === null || typeof root.reconciliation !== "object") {
|
|
2912
|
-
errors.push('Field "reconciliation" must be an object.');
|
|
2913
|
-
}
|
|
2914
|
-
else {
|
|
2915
|
-
const rec = root.reconciliation;
|
|
2916
|
-
if (!isNonNegativeInteger(rec.duplicatesCollapsed)) {
|
|
2917
|
-
errors.push("reconciliation.duplicatesCollapsed must be a non-negative integer.");
|
|
2918
|
-
}
|
|
2919
|
-
if (!Array.isArray(rec.conflicts)) {
|
|
2920
|
-
errors.push("reconciliation.conflicts must be an array.");
|
|
2921
|
-
}
|
|
2922
|
-
else {
|
|
2923
|
-
rec.conflicts.forEach((c, ci) => {
|
|
2924
|
-
if (c === null || typeof c !== "object" || Array.isArray(c)) {
|
|
2925
|
-
errors.push(`reconciliation.conflicts[${ci}] must be an object.`);
|
|
2926
|
-
return;
|
|
2927
|
-
}
|
|
2928
|
-
const co = c;
|
|
2929
|
-
if (!isNonEmptyString(co.findingId)) {
|
|
2930
|
-
errors.push(`reconciliation.conflicts[${ci}].findingId must be a non-empty string.`);
|
|
2931
|
-
}
|
|
2932
|
-
else if (!findingIds.has(co.findingId)) {
|
|
2933
|
-
errors.push(`reconciliation.conflicts[${ci}].findingId references unknown finding "${co.findingId}".`);
|
|
2934
|
-
}
|
|
2935
|
-
if (!isNonEmptyString(co.description)) {
|
|
2936
|
-
errors.push(`reconciliation.conflicts[${ci}].description must be a non-empty string.`);
|
|
2937
|
-
}
|
|
2938
|
-
});
|
|
2939
|
-
}
|
|
2940
|
-
if (!isStringArray(rec.multiSpecialistConfirmed)) {
|
|
2941
|
-
errors.push("reconciliation.multiSpecialistConfirmed must be an array of finding ids.");
|
|
2942
|
-
}
|
|
2943
|
-
else {
|
|
2944
|
-
for (const msId of rec.multiSpecialistConfirmed) {
|
|
2945
|
-
if (!findingIds.has(msId)) {
|
|
2946
|
-
errors.push(`reconciliation.multiSpecialistConfirmed references unknown finding id "${msId}".`);
|
|
2947
|
-
continue;
|
|
2948
|
-
}
|
|
2949
|
-
if (Array.isArray(root.findings)) {
|
|
2950
|
-
const finding = root.findings.find((f) => {
|
|
2951
|
-
return f && typeof f === "object" && !Array.isArray(f) && f.id === msId;
|
|
2952
|
-
});
|
|
2953
|
-
if (finding && typeof finding === "object" && !Array.isArray(finding)) {
|
|
2954
|
-
const reportedBy = finding.reportedBy;
|
|
2955
|
-
const count = Array.isArray(reportedBy)
|
|
2956
|
-
? new Set(reportedBy.filter((v) => typeof v === "string")).size
|
|
2957
|
-
: 0;
|
|
2958
|
-
if (count < 2) {
|
|
2959
|
-
errors.push(`reconciliation.multiSpecialistConfirmed entry "${msId}" must be confirmed by at least 2 distinct reviewers (found ${count}).`);
|
|
2960
|
-
}
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
}
|
|
2965
|
-
if (!isStringArray(rec.shipBlockers)) {
|
|
2966
|
-
errors.push("reconciliation.shipBlockers must be an array of finding ids.");
|
|
2967
|
-
}
|
|
2968
|
-
else {
|
|
2969
|
-
const blockers = new Set(rec.shipBlockers);
|
|
2970
|
-
for (const id of rec.shipBlockers) {
|
|
2971
|
-
if (!findingIds.has(id)) {
|
|
2972
|
-
errors.push(`reconciliation.shipBlockers references unknown finding id "${id}".`);
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
for (const criticalId of openCriticalIds) {
|
|
2976
|
-
if (!blockers.has(criticalId)) {
|
|
2977
|
-
errors.push(`reconciliation.shipBlockers must include open Critical finding "${criticalId}".`);
|
|
2978
|
-
}
|
|
2979
|
-
}
|
|
2980
|
-
}
|
|
2981
|
-
if (isStringArray(rec.multiSpecialistConfirmed)) {
|
|
2982
|
-
for (const id of rec.multiSpecialistConfirmed) {
|
|
2983
|
-
if (!findingIds.has(id)) {
|
|
2984
|
-
errors.push(`reconciliation.multiSpecialistConfirmed references unknown finding id "${id}".`);
|
|
2985
|
-
}
|
|
2986
|
-
}
|
|
2987
|
-
}
|
|
2988
|
-
if (rec.layerCoverage !== undefined) {
|
|
2989
|
-
if (rec.layerCoverage === null || typeof rec.layerCoverage !== "object" || Array.isArray(rec.layerCoverage)) {
|
|
2990
|
-
errors.push("reconciliation.layerCoverage must be an object when present.");
|
|
2991
|
-
}
|
|
2992
|
-
else {
|
|
2993
|
-
const coverage = rec.layerCoverage;
|
|
2994
|
-
for (const source of sourceSet) {
|
|
2995
|
-
if (coverage[source] !== undefined && typeof coverage[source] !== "boolean") {
|
|
2996
|
-
errors.push(`reconciliation.layerCoverage.${source} must be boolean when present.`);
|
|
2997
|
-
}
|
|
2998
|
-
}
|
|
2999
|
-
}
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
return { valid: errors.length === 0, errors };
|
|
3003
|
-
}
|
|
3004
|
-
/**
|
|
3005
|
-
* Ensure the narrative verdict in 07-review.md is consistent with the
|
|
3006
|
-
* structured review-army reconciliation. A review cannot declare
|
|
3007
|
-
* APPROVED while open Critical findings or shipBlockers remain.
|
|
3008
|
-
*/
|
|
3009
|
-
export async function checkReviewVerdictConsistency(projectRoot) {
|
|
3010
|
-
const errors = [];
|
|
3011
|
-
const reviewMdPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review.md");
|
|
3012
|
-
const armyJsonPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review-army.json");
|
|
3013
|
-
let finalVerdict = "UNKNOWN";
|
|
3014
|
-
if (await exists(reviewMdPath)) {
|
|
3015
|
-
const raw = await fs.readFile(reviewMdPath, "utf8");
|
|
3016
|
-
const sections = extractH2Sections(raw);
|
|
3017
|
-
const verdictBody = sectionBodyByName(sections, "Final Verdict");
|
|
3018
|
-
if (verdictBody) {
|
|
3019
|
-
const chosen = [];
|
|
3020
|
-
for (const token of ["APPROVED_WITH_CONCERNS", "APPROVED", "BLOCKED"]) {
|
|
3021
|
-
const regex = new RegExp(`\\b${token}\\b`, "u");
|
|
3022
|
-
if (regex.test(verdictBody)) {
|
|
3023
|
-
// APPROVED would match inside APPROVED_WITH_CONCERNS; prefer the longer match first.
|
|
3024
|
-
if (token === "APPROVED" && /\bAPPROVED_WITH_CONCERNS\b/u.test(verdictBody))
|
|
3025
|
-
continue;
|
|
3026
|
-
chosen.push(token);
|
|
3027
|
-
}
|
|
3028
|
-
}
|
|
3029
|
-
if (chosen.length === 1) {
|
|
3030
|
-
finalVerdict = chosen[0];
|
|
3031
|
-
}
|
|
3032
|
-
else if (chosen.length > 1) {
|
|
3033
|
-
errors.push(`Final Verdict section lists multiple verdict tokens (${chosen.join(", ")}). Select exactly one.`);
|
|
3034
|
-
}
|
|
3035
|
-
else {
|
|
3036
|
-
errors.push('Final Verdict section does not select APPROVED, APPROVED_WITH_CONCERNS, or BLOCKED.');
|
|
3037
|
-
}
|
|
3038
|
-
}
|
|
3039
|
-
else {
|
|
3040
|
-
errors.push('07-review.md is missing the "## Final Verdict" section.');
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
|
-
let openCriticalCount = 0;
|
|
3044
|
-
let shipBlockerCount = 0;
|
|
3045
|
-
if (await exists(armyJsonPath)) {
|
|
3046
|
-
try {
|
|
3047
|
-
const raw = await fs.readFile(armyJsonPath, "utf8");
|
|
3048
|
-
const parsed = JSON.parse(raw);
|
|
3049
|
-
const findings = Array.isArray(parsed.findings) ? parsed.findings : [];
|
|
3050
|
-
for (const f of findings) {
|
|
3051
|
-
if (!f || typeof f !== "object" || Array.isArray(f))
|
|
3052
|
-
continue;
|
|
3053
|
-
const o = f;
|
|
3054
|
-
if (o.severity === "Critical" && o.status === "open") {
|
|
3055
|
-
openCriticalCount++;
|
|
3056
|
-
}
|
|
3057
|
-
}
|
|
3058
|
-
const rec = parsed.reconciliation && typeof parsed.reconciliation === "object" && !Array.isArray(parsed.reconciliation)
|
|
3059
|
-
? parsed.reconciliation
|
|
3060
|
-
: null;
|
|
3061
|
-
if (rec && Array.isArray(rec.shipBlockers)) {
|
|
3062
|
-
shipBlockerCount = rec.shipBlockers.filter((v) => typeof v === "string").length;
|
|
3063
|
-
}
|
|
3064
|
-
}
|
|
3065
|
-
catch {
|
|
3066
|
-
// JSON validity is the concern of validateReviewArmy; skip silently here.
|
|
3067
|
-
}
|
|
3068
|
-
}
|
|
3069
|
-
if (finalVerdict === "APPROVED" && (openCriticalCount > 0 || shipBlockerCount > 0)) {
|
|
3070
|
-
errors.push(`Final Verdict is APPROVED but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Use BLOCKED or APPROVED_WITH_CONCERNS.`);
|
|
3071
|
-
}
|
|
3072
|
-
// APPROVED_WITH_CONCERNS is intended for Important/Suggestion findings
|
|
3073
|
-
// the author has accepted. An *open* Critical finding or an active
|
|
3074
|
-
// shipBlocker must route through BLOCKED (review_verdict_blocked gate)
|
|
3075
|
-
// rather than pass as a concession — previously this slipped through.
|
|
3076
|
-
if (finalVerdict === "APPROVED_WITH_CONCERNS" &&
|
|
3077
|
-
(openCriticalCount > 0 || shipBlockerCount > 0)) {
|
|
3078
|
-
errors.push(`Final Verdict is APPROVED_WITH_CONCERNS but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Resolve them or use BLOCKED.`);
|
|
3079
|
-
}
|
|
3080
|
-
return {
|
|
3081
|
-
ok: errors.length === 0,
|
|
3082
|
-
errors,
|
|
3083
|
-
finalVerdict,
|
|
3084
|
-
openCriticalCount,
|
|
3085
|
-
shipBlockerCount
|
|
3086
|
-
};
|
|
3087
|
-
}
|
|
3088
|
-
export async function checkReviewSecurityNoChangeAttestation(projectRoot) {
|
|
3089
|
-
const reviewMdPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review.md");
|
|
3090
|
-
if (!(await exists(reviewMdPath))) {
|
|
3091
|
-
return {
|
|
3092
|
-
ok: true,
|
|
3093
|
-
errors: [],
|
|
3094
|
-
hasSecurityFinding: false,
|
|
3095
|
-
hasNoChangeAttestation: false
|
|
3096
|
-
};
|
|
3097
|
-
}
|
|
3098
|
-
const errors = [];
|
|
3099
|
-
const raw = await fs.readFile(reviewMdPath, "utf8");
|
|
3100
|
-
const sections = extractH2Sections(raw);
|
|
3101
|
-
const securityBody = sectionBodyByName(sections, "Layer 2 Security")
|
|
3102
|
-
?? sectionBodyByName(sections, "Layer 2b: Security")
|
|
3103
|
-
?? sectionBodyByName(sections, "Layer 2 Findings");
|
|
3104
|
-
if (!securityBody) {
|
|
3105
|
-
errors.push('07-review.md is missing a Layer 2 security section.');
|
|
3106
|
-
return {
|
|
3107
|
-
ok: false,
|
|
3108
|
-
errors,
|
|
3109
|
-
hasSecurityFinding: false,
|
|
3110
|
-
hasNoChangeAttestation: false
|
|
3111
|
-
};
|
|
3112
|
-
}
|
|
3113
|
-
const securityTableRowPattern = /^\|\s*[^|\n]+\|\s*[^|\n]+\|\s*security\s*\|\s*[^|\n]+\|\s*[^|\n]+\|/imu;
|
|
3114
|
-
const securityBulletPattern = /^[*-]\s+.*\b(?:security|auth|injection|secret|credential|permission)\b/imu;
|
|
3115
|
-
const hasSecurityFinding = securityTableRowPattern.test(securityBody) || securityBulletPattern.test(securityBody);
|
|
3116
|
-
const attestationMatch = /\b(NO_CHANGE_ATTESTATION|NO_SECURITY_IMPACT)\b\s*:\s*(.*)/iu.exec(securityBody);
|
|
3117
|
-
const attestationToken = attestationMatch?.[1] ?? "NO_CHANGE_ATTESTATION";
|
|
3118
|
-
const hasNoChangeAttestation = Boolean(attestationMatch && attestationMatch[2]?.trim().length > 0);
|
|
3119
|
-
if (attestationMatch && attestationMatch[2]?.trim().length === 0) {
|
|
3120
|
-
errors.push(`${attestationToken} must include a non-empty rationale.`);
|
|
3121
|
-
}
|
|
3122
|
-
if (!hasSecurityFinding && !hasNoChangeAttestation) {
|
|
3123
|
-
errors.push("Layer 2 security evidence missing: include at least one security finding or `NO_CHANGE_ATTESTATION: <reason>` / `NO_SECURITY_IMPACT: <reason>`.");
|
|
3124
|
-
}
|
|
3125
|
-
return {
|
|
3126
|
-
ok: errors.length === 0,
|
|
3127
|
-
errors,
|
|
3128
|
-
hasSecurityFinding,
|
|
3129
|
-
hasNoChangeAttestation
|
|
3130
|
-
};
|
|
3131
|
-
}
|