@warpgogol/forge 2.21.6 → 2.21.8
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/AGENTS.md +27 -1
- package/os/adr/adr-0000-template.md +8 -0
- package/os/adr/handlers/validate.test.ts +203 -0
- package/os/adr/handlers/validate.ts +54 -1
- package/os/adr/types.ts +7 -0
- package/os/compass/handlers/compass-inventory-handler.ts +11 -1
- package/os/compass/handlers/compass-inventory.ts +10 -0
- package/os/core/handlers/validate.ts +56 -5
- package/os/naming/naming-convention.ts +9 -0
- package/os/plugin/plugin.module.ts +1 -1
- package/os/rfc/acceptance.ts +133 -4
- package/os/rfc/handlers/implement-stamp.ts +14 -1
- package/os/rfc/handlers/validate-rules-rfc0997.test.ts +394 -0
- package/os/rfc/handlers/validate-rules-rfc1006.test.ts +478 -0
- package/os/rfc/handlers/validate-rules.ts +450 -9
- package/os/rfc/handlers/validate.ts +20 -2
- package/os/rfc/rfc-0000-template.md +24 -8
- package/os/rfc/rfc.module.ts +28 -0
- package/os/rfc/types.ts +72 -6
- package/os/rfc/verification-evidence.ts +5 -4
- package/os/rfc/verification-refresh.test.ts +320 -0
- package/os/rfc/verification-refresh.ts +216 -0
- package/os/session/handlers/save.ts +10 -0
- package/os/spec/spec-validate.test.ts +59 -0
- package/os/spec/spec-validate.ts +6 -4
- package/package.json +2 -1
- package/skills/fo/fo-handoff/SKILL.md +15 -6
- package/skills/fo/fo-idea-audit/SKILL.md +1 -1
- package/skills/fo/fo-idea-create-rfc/SKILL.md +1 -1
- package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +75 -0
- package/skills/fo/fo-idea-implement/SKILL.md +3 -2
- package/src/compass/contract-registry.ts +25 -6
- package/src/index.ts +1 -1
- package/src/onboarding/doctor.ts +1 -1
- package/src/registry.ts +1 -1
- package/src/tests/acceptance-probe-kinds.test.ts +262 -0
- package/src/tests/plugin-manifest.test.ts +1 -1
- package/src/tests/session-handlers.test.ts +29 -0
- package/src/types/werkstatt-engine-shims.d.ts +0 -21
- package/src/types/werkstatt-shared-shims.d.ts +68 -147
- /package/src/plugin/{ForgePluginManifest.ts → forge-plugin-manifest.ts} +0 -0
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
<item>RFC-0722: add RFC-DIR-01 directory structure warning rule for unsanctioned subdirectories.</item>
|
|
11
11
|
<item>RFC-0755: add V-RFC-33 frontmatter YAML parseability check (checkFrontmatterYamlParse helper).</item>
|
|
12
12
|
<item>RFC-0795: add V-33 dependsOn referential integrity/self-dependency/rejected-dependency and V-34 batch slug format rules.</item>
|
|
13
|
+
<item>RFC-0997: add V-35 probe→criterion referential integrity, V-36 criterion identifier discipline, V-37 evidence mechanism validity, and computeProbeCoverage for non-blocking coverage reports.</item>
|
|
14
|
+
<item>RFC-1006: add V-38 document readiness completeness, V-39 non-atomic criterion, V-40 unbounded quantity, V-41 weasel verb, V-42 criterion versioning annotation format. Add evaluateDocumentReadiness and extend evaluateAcceptanceCriteria with supersession tracking and reject-checklist scans.</item>
|
|
13
15
|
</CHANGE_SUMMARY>
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import path from "node:path";
|
|
17
|
-
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { readFile, stat } from "node:fs/promises";
|
|
18
20
|
import { execFile } from "node:child_process";
|
|
19
21
|
|
|
20
22
|
import { parse as yamlParse } from "yaml";
|
|
@@ -31,7 +33,10 @@ import {
|
|
|
31
33
|
RFC_FULL_REQUIRED_SECTIONS,
|
|
32
34
|
RFC_METADATA_CUTOFF,
|
|
33
35
|
RFC_VERSION_BUMP_CUTOFF,
|
|
36
|
+
RFC_PROBE_BINDING_CUTOFF,
|
|
37
|
+
RFC_CRITERIA_CONTENT_CUTOFF,
|
|
34
38
|
} from "../types.ts";
|
|
39
|
+
import type { ProbeCoverageReport, AcceptanceProbe } from "../types.ts";
|
|
35
40
|
import { DNA_DOCS, AP_DOCS } from "./shared.ts";
|
|
36
41
|
|
|
37
42
|
const DNA_DOC = DNA_DOCS[0]!;
|
|
@@ -63,6 +68,123 @@ export interface AcceptanceCriteriaEvaluation {
|
|
|
63
68
|
totalUnchecked: number;
|
|
64
69
|
uncheckedLines: string[];
|
|
65
70
|
checkedWithoutEvidence: string[];
|
|
71
|
+
criterionIds: string[];
|
|
72
|
+
duplicateCriterionIds: string[];
|
|
73
|
+
linesWithoutId: string[];
|
|
74
|
+
supersededIds: string[];
|
|
75
|
+
nonAtomicViolations: { line: string; acId: string }[];
|
|
76
|
+
unboundedQuantityViolations: { line: string; acId: string; trigger: string }[];
|
|
77
|
+
weaselVerbViolations: { line: string; acId: string; trigger: string }[];
|
|
78
|
+
malformedSupersessionAnnotations: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DocumentReadinessEvaluation {
|
|
82
|
+
totalChecked: number;
|
|
83
|
+
totalUnchecked: number;
|
|
84
|
+
uncheckedLines: string[];
|
|
85
|
+
criterionIds: string[];
|
|
86
|
+
linesWithoutId: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── RFC-1006: reject checklist constants ──────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const UNBOUNDED_QUANTITY_TRIGGERS = [
|
|
92
|
+
"fast",
|
|
93
|
+
"quick",
|
|
94
|
+
"scalable",
|
|
95
|
+
"reasonable load",
|
|
96
|
+
"most requests",
|
|
97
|
+
"efficiently",
|
|
98
|
+
"high performance",
|
|
99
|
+
"low latency",
|
|
100
|
+
"responsively",
|
|
101
|
+
"smoothly",
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
const WEASEL_VERBS = [
|
|
105
|
+
"handle gracefully",
|
|
106
|
+
"behave correctly",
|
|
107
|
+
"as appropriate",
|
|
108
|
+
"robust",
|
|
109
|
+
"user-friendly",
|
|
110
|
+
"works correctly",
|
|
111
|
+
"is reliable",
|
|
112
|
+
"gracefully handle",
|
|
113
|
+
"correctly handle",
|
|
114
|
+
"properly handle",
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
// Detects "SHALL <verb-phrase> and <verb-phrase>" — non-atomic criterion.
|
|
118
|
+
// Conservative: only fires when "and" appears after SHALL/SHOULD/MUST and
|
|
119
|
+
// is followed by a verb-like word (a-z, not a noun-phrase connector).
|
|
120
|
+
const NON_ATOMIC_PATTERN =
|
|
121
|
+
/\b(?:SHALL|SHOULD|MUST)\s+\S[^()]*?\band\s+(?:return|report|exit|emit|write|read|create|delete|update|persist|validate|reject|accept|log|send|store|load|fetch|process|register|enqueue|schedule|trigger|notify|redirect|render|display|show|hide|enable|disable|start|stop|restart|check|verify|ensure|prevent|allow|block|deny|grant|revoke|parse|serialize|deserialize|encode|decode|compress|decompress|encrypt|decrypt|hash|sign|verify|transform|convert|map|filter|sort|group|aggregate|count|sum|average|min|max|find|search|resolve|lookup)\b/i;
|
|
122
|
+
|
|
123
|
+
const SUPERSEDED_ANNOTATION_PATTERN =
|
|
124
|
+
/^>\s*Superseded\s+(AC-\d+)\s+\((\d{4}-\d{2}-\d{2})\):\s*(.+)$/;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Strip fenced code blocks (``` ... ```) from a markdown body.
|
|
128
|
+
* Prevents section extractors from matching headings inside code block examples.
|
|
129
|
+
* Pure function — no I/O, no side effects.
|
|
130
|
+
*/
|
|
131
|
+
export function stripFencedCodeBlocks(body: string): string {
|
|
132
|
+
return body.replace(/```[\s\S]*?```/g, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract the raw document-readiness section text from an RFC body.
|
|
137
|
+
* Pure function — no I/O, no side effects.
|
|
138
|
+
*/
|
|
139
|
+
export function extractDocumentReadinessSection(body: string): string | null {
|
|
140
|
+
const stripped = stripFencedCodeBlocks(body);
|
|
141
|
+
const match = stripped.match(/## Document readiness\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
|
142
|
+
return match ? match[1]! : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Evaluate document-readiness checkboxes (RFC-1006).
|
|
147
|
+
* Pure function — no I/O, no side effects.
|
|
148
|
+
*/
|
|
149
|
+
export function evaluateDocumentReadiness(body: string): DocumentReadinessEvaluation {
|
|
150
|
+
const section = extractDocumentReadinessSection(body);
|
|
151
|
+
if (!section) {
|
|
152
|
+
return {
|
|
153
|
+
totalChecked: 0,
|
|
154
|
+
totalUnchecked: 0,
|
|
155
|
+
uncheckedLines: [],
|
|
156
|
+
criterionIds: [],
|
|
157
|
+
linesWithoutId: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const uncheckedLines = section
|
|
162
|
+
.split("\n")
|
|
163
|
+
.filter((line) => /^- \[ \]/.test(line))
|
|
164
|
+
.map((line) => line.trim());
|
|
165
|
+
const totalUnchecked = uncheckedLines.length;
|
|
166
|
+
|
|
167
|
+
const checkedLines = section.split("\n").filter((line) => /^- \[x\]/.test(line));
|
|
168
|
+
|
|
169
|
+
const checklistLines = section.split("\n").filter((line) => /^- \[[ x]\]/.test(line));
|
|
170
|
+
const criterionIds: string[] = [];
|
|
171
|
+
const linesWithoutId: string[] = [];
|
|
172
|
+
for (const line of checklistLines) {
|
|
173
|
+
const match = line.match(/^\s*- \[[ x]\]\s*DR-(\d+):/);
|
|
174
|
+
if (match) {
|
|
175
|
+
criterionIds.push(`DR-${match[1]}`);
|
|
176
|
+
} else {
|
|
177
|
+
linesWithoutId.push(line.trim());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
totalChecked: checkedLines.length,
|
|
183
|
+
totalUnchecked,
|
|
184
|
+
uncheckedLines,
|
|
185
|
+
criterionIds,
|
|
186
|
+
linesWithoutId,
|
|
187
|
+
};
|
|
66
188
|
}
|
|
67
189
|
|
|
68
190
|
/**
|
|
@@ -70,7 +192,8 @@ export interface AcceptanceCriteriaEvaluation {
|
|
|
70
192
|
* Returns `undefined` when the section is absent.
|
|
71
193
|
*/
|
|
72
194
|
export function extractAcceptanceCriteriaSection(body: string): string | undefined {
|
|
73
|
-
const
|
|
195
|
+
const stripped = stripFencedCodeBlocks(body);
|
|
196
|
+
const match = stripped.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
|
74
197
|
return match?.[1];
|
|
75
198
|
}
|
|
76
199
|
|
|
@@ -87,6 +210,14 @@ export function evaluateAcceptanceCriteria(body: string): AcceptanceCriteriaEval
|
|
|
87
210
|
totalUnchecked: 0,
|
|
88
211
|
uncheckedLines: [],
|
|
89
212
|
checkedWithoutEvidence: [],
|
|
213
|
+
criterionIds: [],
|
|
214
|
+
duplicateCriterionIds: [],
|
|
215
|
+
linesWithoutId: [],
|
|
216
|
+
supersededIds: [],
|
|
217
|
+
nonAtomicViolations: [],
|
|
218
|
+
unboundedQuantityViolations: [],
|
|
219
|
+
weaselVerbViolations: [],
|
|
220
|
+
malformedSupersessionAnnotations: [],
|
|
90
221
|
};
|
|
91
222
|
}
|
|
92
223
|
|
|
@@ -104,11 +235,149 @@ export function evaluateAcceptanceCriteria(body: string): AcceptanceCriteriaEval
|
|
|
104
235
|
}
|
|
105
236
|
}
|
|
106
237
|
|
|
238
|
+
const checklistLines = section.split("\n").filter((line) => /^- \[[ x]\]/.test(line));
|
|
239
|
+
const criterionIds: string[] = [];
|
|
240
|
+
const seenIds = new Set<string>();
|
|
241
|
+
const duplicateCriterionIds: string[] = [];
|
|
242
|
+
const linesWithoutId: string[] = [];
|
|
243
|
+
for (const line of checklistLines) {
|
|
244
|
+
const match = line.match(/^\s*- \[[ x]\]\s*AC-(\d+):/);
|
|
245
|
+
if (match) {
|
|
246
|
+
const id = `AC-${match[1]}`;
|
|
247
|
+
if (seenIds.has(id)) {
|
|
248
|
+
duplicateCriterionIds.push(id);
|
|
249
|
+
} else {
|
|
250
|
+
seenIds.add(id);
|
|
251
|
+
criterionIds.push(id);
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
linesWithoutId.push(line.trim());
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// RFC-1006: detect supersession annotations and exclude superseded criteria
|
|
259
|
+
const sectionLines = section.split("\n");
|
|
260
|
+
const supersededIds: string[] = [];
|
|
261
|
+
const supersededSet = new Set<string>();
|
|
262
|
+
const malformedSupersessionAnnotations: string[] = [];
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < sectionLines.length; i++) {
|
|
265
|
+
const line = sectionLines[i]!;
|
|
266
|
+
const annMatch = line.match(SUPERSEDED_ANNOTATION_PATTERN);
|
|
267
|
+
if (annMatch) {
|
|
268
|
+
const acId = annMatch[1]!;
|
|
269
|
+
const dateStr = annMatch[2]!;
|
|
270
|
+
supersededIds.push(acId);
|
|
271
|
+
supersededSet.add(acId);
|
|
272
|
+
// Validate date format (already captured by regex, but check range)
|
|
273
|
+
const date = new Date(dateStr);
|
|
274
|
+
if (isNaN(date.getTime())) {
|
|
275
|
+
malformedSupersessionAnnotations.push(line.trim());
|
|
276
|
+
}
|
|
277
|
+
} else if (
|
|
278
|
+
/^>\s*Superseded\s+AC-/.test(line.trim()) &&
|
|
279
|
+
!SUPERSEDED_ANNOTATION_PATTERN.test(line.trim())
|
|
280
|
+
) {
|
|
281
|
+
// Line starts with supersession annotation pattern but doesn't match full format
|
|
282
|
+
malformedSupersessionAnnotations.push(line.trim());
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Remove superseded criteria from unchecked count
|
|
287
|
+
const effectiveUncheckedLines = uncheckedLines.filter((line) => {
|
|
288
|
+
const match = line.match(/^\s*- \[ \]\s*AC-(\d+):/);
|
|
289
|
+
if (match) {
|
|
290
|
+
return !supersededSet.has(`AC-${match[1]}`);
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// RFC-1006: reject checklist scans on criterion text
|
|
296
|
+
const nonAtomicViolations: { line: string; acId: string }[] = [];
|
|
297
|
+
const unboundedQuantityViolations: { line: string; acId: string; trigger: string }[] = [];
|
|
298
|
+
const weaselVerbViolations: { line: string; acId: string; trigger: string }[] = [];
|
|
299
|
+
|
|
300
|
+
for (const line of checklistLines) {
|
|
301
|
+
const acMatch = line.match(/^\s*- \[[ x]\]\s*(AC-\d+):/);
|
|
302
|
+
if (!acMatch) continue;
|
|
303
|
+
const acId = acMatch[1]!;
|
|
304
|
+
if (supersededSet.has(acId)) continue;
|
|
305
|
+
|
|
306
|
+
const text = line.toLowerCase();
|
|
307
|
+
|
|
308
|
+
// V-39: non-atomic criterion
|
|
309
|
+
if (NON_ATOMIC_PATTERN.test(line)) {
|
|
310
|
+
nonAtomicViolations.push({ line: line.trim(), acId });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// V-40: unbounded quantity
|
|
314
|
+
for (const trigger of UNBOUNDED_QUANTITY_TRIGGERS) {
|
|
315
|
+
if (text.includes(trigger)) {
|
|
316
|
+
unboundedQuantityViolations.push({ line: line.trim(), acId, trigger });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// V-41: weasel verbs
|
|
321
|
+
for (const trigger of WEASEL_VERBS) {
|
|
322
|
+
if (text.includes(trigger)) {
|
|
323
|
+
weaselVerbViolations.push({ line: line.trim(), acId, trigger });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
107
328
|
return {
|
|
108
329
|
totalChecked: checkedLines.length,
|
|
109
|
-
totalUnchecked,
|
|
110
|
-
uncheckedLines,
|
|
330
|
+
totalUnchecked: effectiveUncheckedLines.length,
|
|
331
|
+
uncheckedLines: effectiveUncheckedLines,
|
|
111
332
|
checkedWithoutEvidence,
|
|
333
|
+
criterionIds,
|
|
334
|
+
duplicateCriterionIds,
|
|
335
|
+
linesWithoutId,
|
|
336
|
+
supersededIds,
|
|
337
|
+
nonAtomicViolations,
|
|
338
|
+
unboundedQuantityViolations,
|
|
339
|
+
weaselVerbViolations,
|
|
340
|
+
malformedSupersessionAnnotations,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ─── RFC-0997: probe-criterion binding and coverage ────────────────────────
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Compute a non-blocking probe coverage report for an RFC.
|
|
348
|
+
* Pure function — no I/O, no side effects.
|
|
349
|
+
*/
|
|
350
|
+
export function computeProbeCoverage(
|
|
351
|
+
criterionIds: string[],
|
|
352
|
+
acceptance: unknown,
|
|
353
|
+
): ProbeCoverageReport {
|
|
354
|
+
const probes = Array.isArray(acceptance) ? (acceptance as AcceptanceProbe[]) : [];
|
|
355
|
+
const probeBackedCriteria = new Set<string>();
|
|
356
|
+
const unboundProbes: string[] = [];
|
|
357
|
+
|
|
358
|
+
for (const probe of probes) {
|
|
359
|
+
const criterion = probe.criterion;
|
|
360
|
+
if (typeof criterion === "string" && /^AC-\d+$/.test(criterion)) {
|
|
361
|
+
if (criterionIds.includes(criterion)) {
|
|
362
|
+
probeBackedCriteria.add(criterion);
|
|
363
|
+
} else {
|
|
364
|
+
unboundProbes.push(criterion);
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
unboundProbes.push(String(criterion ?? "(missing)"));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const uncoveredCriteria = criterionIds.filter((id) => !probeBackedCriteria.has(id));
|
|
372
|
+
const totalCriteria = criterionIds.length;
|
|
373
|
+
const probeBackedCount = probeBackedCriteria.size;
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
totalCriteria,
|
|
377
|
+
probeBackedCriteria: probeBackedCount,
|
|
378
|
+
coverageRatio: totalCriteria > 0 ? probeBackedCount / totalCriteria : 0,
|
|
379
|
+
unboundProbes,
|
|
380
|
+
uncoveredCriteria,
|
|
112
381
|
};
|
|
113
382
|
}
|
|
114
383
|
|
|
@@ -431,9 +700,9 @@ export async function validateSingleRfc(
|
|
|
431
700
|
}
|
|
432
701
|
|
|
433
702
|
// V-14: acceptance criteria must have at least 3 checklist items
|
|
434
|
-
const
|
|
435
|
-
if (
|
|
436
|
-
const checklistItems =
|
|
703
|
+
const acceptanceSection = extractAcceptanceCriteriaSection(body);
|
|
704
|
+
if (acceptanceSection) {
|
|
705
|
+
const checklistItems = acceptanceSection.match(/^- \[[ x]\]/gm);
|
|
437
706
|
const count = checklistItems?.length ?? 0;
|
|
438
707
|
if (count < 3) {
|
|
439
708
|
addViolation(
|
|
@@ -449,8 +718,8 @@ export async function validateSingleRfc(
|
|
|
449
718
|
// V-26: implemented RFCs must have all acceptance criteria checked (RFC-0463)
|
|
450
719
|
// V-27: every checked criterion must carry inline (evidence: ...) annotation (RFC-0463)
|
|
451
720
|
// RFC-0476: both use the shared evaluateAcceptanceCriteria function.
|
|
452
|
-
|
|
453
|
-
|
|
721
|
+
const criteriaEval = evaluateAcceptanceCriteria(body);
|
|
722
|
+
if (acceptanceSection) {
|
|
454
723
|
if (status === "implemented" && criteriaEval.totalUnchecked > 0 && !isArchived) {
|
|
455
724
|
addViolation(
|
|
456
725
|
rfcId,
|
|
@@ -468,6 +737,178 @@ export async function validateSingleRfc(
|
|
|
468
737
|
`checked acceptance criterion lacks inline (evidence: ...) annotation: "${line}"`,
|
|
469
738
|
);
|
|
470
739
|
}
|
|
740
|
+
|
|
741
|
+
// V-35/V-36/V-37: probe-criterion binding rules (RFC-0997, post-cutoff only)
|
|
742
|
+
const isPostCutoff = createdAt >= RFC_PROBE_BINDING_CUTOFF && !isArchived;
|
|
743
|
+
if (isPostCutoff) {
|
|
744
|
+
const acceptance = fm["acceptance"];
|
|
745
|
+
const probes = Array.isArray(acceptance) ? (acceptance as AcceptanceProbe[]) : [];
|
|
746
|
+
|
|
747
|
+
// V-35: every probe SHALL declare criterion referencing an existing AC-N id
|
|
748
|
+
for (let i = 0; i < probes.length; i++) {
|
|
749
|
+
const probe = probes[i]!;
|
|
750
|
+
const criterion = probe.criterion;
|
|
751
|
+
if (criterion === undefined || criterion === null) {
|
|
752
|
+
addViolation(
|
|
753
|
+
rfcId,
|
|
754
|
+
relFile,
|
|
755
|
+
"V-35",
|
|
756
|
+
`acceptance probe ${i} ("${probe.probe}") lacks required "criterion" field (post-cutoff RFCs must bind probes to AC-N criteria per RFC-0997)`,
|
|
757
|
+
);
|
|
758
|
+
} else if (typeof criterion !== "string" || !/^AC-\d+$/.test(criterion)) {
|
|
759
|
+
addViolation(
|
|
760
|
+
rfcId,
|
|
761
|
+
relFile,
|
|
762
|
+
"V-35",
|
|
763
|
+
`acceptance probe ${i} ("${probe.probe}") has criterion "${String(criterion)}" which does not match format "AC-N"`,
|
|
764
|
+
);
|
|
765
|
+
} else if (!criteriaEval.criterionIds.includes(criterion)) {
|
|
766
|
+
addViolation(
|
|
767
|
+
rfcId,
|
|
768
|
+
relFile,
|
|
769
|
+
"V-35",
|
|
770
|
+
`acceptance probe ${i} ("${probe.probe}") declares criterion "${criterion}" which does not exist in acceptance criteria`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// V-36: every top-level checklist line SHALL start with a unique AC-N: identifier
|
|
776
|
+
for (const line of criteriaEval.linesWithoutId) {
|
|
777
|
+
addViolation(
|
|
778
|
+
rfcId,
|
|
779
|
+
relFile,
|
|
780
|
+
"V-36",
|
|
781
|
+
`acceptance criterion checklist line lacks "AC-N:" identifier: "${line}"`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
for (const dupId of criteriaEval.duplicateCriterionIds) {
|
|
785
|
+
addViolation(
|
|
786
|
+
rfcId,
|
|
787
|
+
relFile,
|
|
788
|
+
"V-36",
|
|
789
|
+
`duplicate acceptance criterion identifier "${dupId}" — each AC-N must be unique`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// V-37: checked criteria evidence SHALL resolve
|
|
794
|
+
if (acceptanceSection) {
|
|
795
|
+
const checkedLines = acceptanceSection
|
|
796
|
+
.split("\n")
|
|
797
|
+
.filter((line: string) => /^- \[x\]/.test(line));
|
|
798
|
+
for (const line of checkedLines) {
|
|
799
|
+
const evidenceMatch = line.match(/\(evidence:\s*(.+?)\)/);
|
|
800
|
+
if (!evidenceMatch) continue;
|
|
801
|
+
const evidence = evidenceMatch[1]!.trim();
|
|
802
|
+
|
|
803
|
+
if (evidence.startsWith("probe:")) {
|
|
804
|
+
const refId = evidence.slice("probe:".length).trim();
|
|
805
|
+
const hasProbe = probes.some((p) => p.criterion === refId);
|
|
806
|
+
if (!criteriaEval.criterionIds.includes(refId)) {
|
|
807
|
+
addViolation(
|
|
808
|
+
rfcId,
|
|
809
|
+
relFile,
|
|
810
|
+
"V-37",
|
|
811
|
+
`evidence "probe:${refId}" references a criterion that does not exist in acceptance criteria`,
|
|
812
|
+
);
|
|
813
|
+
} else if (!hasProbe) {
|
|
814
|
+
addViolation(
|
|
815
|
+
rfcId,
|
|
816
|
+
relFile,
|
|
817
|
+
"V-37",
|
|
818
|
+
`evidence "probe:${refId}" references a criterion with no bound probe`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} else if (evidence.startsWith("test:")) {
|
|
822
|
+
const testPath = evidence.slice("test:".length).trim();
|
|
823
|
+
try {
|
|
824
|
+
await stat(path.join(workspaceRoot, testPath));
|
|
825
|
+
} catch {
|
|
826
|
+
addViolation(
|
|
827
|
+
rfcId,
|
|
828
|
+
relFile,
|
|
829
|
+
"V-37",
|
|
830
|
+
`evidence "test:${testPath}" references a file that does not exist`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
} else if (/^[^:]+:\d+$/.test(evidence)) {
|
|
834
|
+
const lastColon = evidence.lastIndexOf(":");
|
|
835
|
+
const filePath = evidence.slice(0, lastColon).trim();
|
|
836
|
+
try {
|
|
837
|
+
await stat(path.join(workspaceRoot, filePath));
|
|
838
|
+
} catch {
|
|
839
|
+
addViolation(
|
|
840
|
+
rfcId,
|
|
841
|
+
relFile,
|
|
842
|
+
"V-37",
|
|
843
|
+
`evidence "${evidence}" references a file that does not exist`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// V-38..V-42: criteria content rules (RFC-1006, post-cutoff only)
|
|
853
|
+
const isCriteriaPostCutoff = createdAt >= RFC_CRITERIA_CONTENT_CUTOFF && !isArchived;
|
|
854
|
+
if (isCriteriaPostCutoff) {
|
|
855
|
+
const severity: "error" | "warning" = status === "draft" ? "warning" : "error";
|
|
856
|
+
|
|
857
|
+
// V-38: document readiness completeness for accepted+ RFCs
|
|
858
|
+
const drEval = evaluateDocumentReadiness(body);
|
|
859
|
+
if (drEval.totalUnchecked > 0 && (status === "accepted" || status === "implemented")) {
|
|
860
|
+
addViolation(
|
|
861
|
+
rfcId,
|
|
862
|
+
relFile,
|
|
863
|
+
"V-38",
|
|
864
|
+
`status is "${status}" but ${drEval.totalUnchecked} document readiness criteria are unchecked in "## Document readiness" section.`,
|
|
865
|
+
severity,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// V-39: non-atomic criterion
|
|
870
|
+
for (const v of criteriaEval.nonAtomicViolations) {
|
|
871
|
+
addViolation(
|
|
872
|
+
rfcId,
|
|
873
|
+
relFile,
|
|
874
|
+
"V-39",
|
|
875
|
+
`acceptance criterion ${v.acId} is non-atomic (contains "and" joining two behaviors): "${v.line}". Split into separate criteria.`,
|
|
876
|
+
severity,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// V-40: unbounded quantity
|
|
881
|
+
for (const v of criteriaEval.unboundedQuantityViolations) {
|
|
882
|
+
addViolation(
|
|
883
|
+
rfcId,
|
|
884
|
+
relFile,
|
|
885
|
+
"V-40",
|
|
886
|
+
`acceptance criterion ${v.acId} contains unbounded quantity "${v.trigger}": state a specific number or reference the decision that will set it.`,
|
|
887
|
+
severity,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// V-41: weasel verb
|
|
892
|
+
for (const v of criteriaEval.weaselVerbViolations) {
|
|
893
|
+
addViolation(
|
|
894
|
+
rfcId,
|
|
895
|
+
relFile,
|
|
896
|
+
"V-41",
|
|
897
|
+
`acceptance criterion ${v.acId} contains weasel verb "${v.trigger}": replace with a specific observable behavior.`,
|
|
898
|
+
severity,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// V-42: malformed supersession annotation
|
|
903
|
+
for (const line of criteriaEval.malformedSupersessionAnnotations) {
|
|
904
|
+
addViolation(
|
|
905
|
+
rfcId,
|
|
906
|
+
relFile,
|
|
907
|
+
"V-42",
|
|
908
|
+
`malformed criterion supersession annotation (expected "> Superseded AC-N (YYYY-MM-DD): <reason>"): "${line}"`,
|
|
909
|
+
"error",
|
|
910
|
+
);
|
|
911
|
+
}
|
|
471
912
|
}
|
|
472
913
|
|
|
473
914
|
// V-15: title consistency
|
|
@@ -24,10 +24,16 @@ import type {
|
|
|
24
24
|
ForgeCommandResult,
|
|
25
25
|
ForgeRuntimeContext,
|
|
26
26
|
} from "../../../src/types.ts";
|
|
27
|
-
import type {
|
|
28
|
-
|
|
27
|
+
import type {
|
|
28
|
+
RfcValidationViolation,
|
|
29
|
+
RfcValidationResult,
|
|
30
|
+
Marker,
|
|
31
|
+
ProbeCoverageReport,
|
|
32
|
+
} from "../types.ts";
|
|
33
|
+
import { RFC_DIR, RFC_KNOWN_KEYS, RFC_PROBE_BINDING_CUTOFF } from "../types.ts";
|
|
29
34
|
import { DNA_DOCS, AP_DOCS, loadInvariantIds } from "./shared.ts";
|
|
30
35
|
import { collectRfcCommandLifecycleViolations } from "./lifecycle.ts";
|
|
36
|
+
import { evaluateAcceptanceCriteria, computeProbeCoverage } from "./validate-rules.ts";
|
|
31
37
|
import { validateSingleRfc, checkFrontmatterYamlParse } from "./validate-rules.ts";
|
|
32
38
|
|
|
33
39
|
export async function runRfcValidate(
|
|
@@ -60,6 +66,7 @@ export async function runRfcValidate(
|
|
|
60
66
|
|
|
61
67
|
const violations: RfcValidationViolation[] = [];
|
|
62
68
|
const allMarkers: Marker[] = [];
|
|
69
|
+
const coverage: Array<{ rfcId: string; report: ProbeCoverageReport }> = [];
|
|
63
70
|
|
|
64
71
|
function addViolation(
|
|
65
72
|
rfcId: string,
|
|
@@ -108,6 +115,16 @@ export async function runRfcValidate(
|
|
|
108
115
|
seenFilenameNumbers,
|
|
109
116
|
);
|
|
110
117
|
allMarkers.push(...markers);
|
|
118
|
+
|
|
119
|
+
const fm = result.parsed.frontmatter;
|
|
120
|
+
const rfcId = String(fm["id"] ?? "");
|
|
121
|
+
const createdAt = String(fm["createdAt"] ?? "");
|
|
122
|
+
const isArchived = fileName.startsWith("archive/");
|
|
123
|
+
if (createdAt >= RFC_PROBE_BINDING_CUTOFF && !isArchived) {
|
|
124
|
+
const criteriaEval = evaluateAcceptanceCriteria(result.parsed.body);
|
|
125
|
+
const report = computeProbeCoverage(criteriaEval.criterionIds, fm["acceptance"]);
|
|
126
|
+
coverage.push({ rfcId, report });
|
|
127
|
+
}
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
const lifecycle = await collectRfcCommandLifecycleViolations(
|
|
@@ -161,6 +178,7 @@ export async function runRfcValidate(
|
|
|
161
178
|
count: filesToValidate.length,
|
|
162
179
|
violations,
|
|
163
180
|
markers: allMarkers,
|
|
181
|
+
coverage: coverage.length > 0 ? coverage : undefined,
|
|
164
182
|
},
|
|
165
183
|
exitCode: hasErrors ? 1 : 0,
|
|
166
184
|
summary: hasErrors
|
|
@@ -57,14 +57,18 @@ nonGoals: []
|
|
|
57
57
|
# automatically inside build pipelines).
|
|
58
58
|
# acceptance:
|
|
59
59
|
# - probe: run
|
|
60
|
+
# criterion: AC-1
|
|
60
61
|
# command: "pnpm exec forge some.command.validate"
|
|
61
62
|
# expect:
|
|
62
63
|
# exitCode: 0
|
|
63
64
|
# - probe: file-exists
|
|
65
|
+
# criterion: AC-2
|
|
64
66
|
# path: "packages/my-package/src/some-new-module.ts"
|
|
65
67
|
# - probe: command-registered
|
|
68
|
+
# criterion: AC-3
|
|
66
69
|
# name: "some.new.command"
|
|
67
70
|
# - probe: file-contains
|
|
71
|
+
# criterion: AC-4
|
|
68
72
|
# path: "AGENTS.md"
|
|
69
73
|
# pattern: "Some new governance paragraph"
|
|
70
74
|
---
|
|
@@ -194,16 +198,28 @@ interface ExampleResult {
|
|
|
194
198
|
Include: performance impact, false positive rate, maintenance burden,
|
|
195
199
|
risk of agents misinterpreting this RFC. -->
|
|
196
200
|
|
|
201
|
+
## Document readiness
|
|
202
|
+
|
|
203
|
+
<!-- Optional (RFC-1006). RFC-phase criteria: document quality gates that verify
|
|
204
|
+
the RFC itself is ready for acceptance. These are NOT EARS statements about
|
|
205
|
+
system behavior — they are checks that the document is complete.
|
|
206
|
+
Use DR-N: identifiers. V-38 enforces completeness for accepted+ post-cutoff RFCs.
|
|
207
|
+
|
|
208
|
+
- [ ] DR-1: Every considered alternative has a stated reason for rejection (evidence: file: <path:line>)
|
|
209
|
+
- [ ] DR-2: The RFC enumerates every existing artifact it touches (evidence: file: <path:line>)
|
|
210
|
+
- [ ] DR-3: The RFC states whether the decision is reversible and at what cost
|
|
211
|
+
-->
|
|
212
|
+
|
|
197
213
|
## Acceptance criteria
|
|
198
214
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
- [ ]
|
|
204
|
-
- [ ]
|
|
205
|
-
- [ ]
|
|
206
|
-
- [ ] `
|
|
215
|
+
<!-- Authoring standard: see packages/forge/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md (RFC-0996).
|
|
216
|
+
Each criterion is a falsifiable claim about an observable artifact, written in EARS form with a stable AC-N: identifier.
|
|
217
|
+
Required mix: behavior, contract, negative, sync. 3–10 items. One claim = one checking mechanism. -->
|
|
218
|
+
|
|
219
|
+
- [ ] AC-1: WHEN `<command> --json` is invoked, THE command SHALL return a JSON object matching the documented output schema (evidence: probe:AC-1 or test: <path>)
|
|
220
|
+
- [ ] AC-2: THE `<command>` SHALL be registered in the kernel module with the correct name and scope (evidence: file: <module-path>)
|
|
221
|
+
- [ ] AC-3: IF `<command>` receives invalid input, THEN THE command SHALL report a blocking error and exit non-zero (evidence: test: <path/to/test>)
|
|
222
|
+
- [ ] AC-4: THE relevant `AGENTS.md` SHALL reference this RFC where agent behavior rules changed (evidence: file: <path:line>)
|
|
207
223
|
|
|
208
224
|
## Implementation notes for agents
|
|
209
225
|
|
package/os/rfc/rfc.module.ts
CHANGED
|
@@ -34,6 +34,7 @@ export const forgeRfcModule: ForgeModule = {
|
|
|
34
34
|
} = await import("./handlers.ts");
|
|
35
35
|
const { runRfcAcceptanceRun } = await import("./acceptance.ts");
|
|
36
36
|
const { runRfcVerificationEmit } = await import("./verification-evidence.ts");
|
|
37
|
+
const { runRfcVerificationRefresh } = await import("./verification-refresh.ts");
|
|
37
38
|
const { runRfcDnaTraceValidate, runRfcDnaTraceGenerate } = await import("./dna-trace.ts");
|
|
38
39
|
const { runRfcDecisionLogGenerate } = await import("./decision-log.ts");
|
|
39
40
|
const { runRfcSupersedePropose } = await import("./handlers/supersede-propose.ts");
|
|
@@ -259,6 +260,33 @@ export const forgeRfcModule: ForgeModule = {
|
|
|
259
260
|
execute: runRfcVerificationEmit,
|
|
260
261
|
});
|
|
261
262
|
|
|
263
|
+
// ── rfc.verification.refresh ────────────────────────────────────────────
|
|
264
|
+
registry.registerCommand({
|
|
265
|
+
name: "rfc.verification.refresh",
|
|
266
|
+
description:
|
|
267
|
+
"RFC-0999: re-run acceptance probes for implemented RFC(s) and update verification " +
|
|
268
|
+
"evidence envelopes in-place. Preserves emittedAt, adds lastRefreshedAt, replaces " +
|
|
269
|
+
"probes[] with fresh results. Requires --id <rfc-id> or --all. Use --dry-run to " +
|
|
270
|
+
"run probes without writing envelope files.",
|
|
271
|
+
scope: "workspace",
|
|
272
|
+
mutatesState: true,
|
|
273
|
+
writes: ["docs/rfcs/verification/*.generated.yaml"],
|
|
274
|
+
reads: ["docs/rfcs/**/*.md", "docs/rfcs/verification/*.generated.yaml"],
|
|
275
|
+
cacheable: false,
|
|
276
|
+
flags: {
|
|
277
|
+
id: { kind: "string", description: "Target a single RFC by id (e.g. rfc-0330)." },
|
|
278
|
+
all: {
|
|
279
|
+
kind: "boolean",
|
|
280
|
+
description: "Refresh all implemented RFCs with existing evidence envelopes.",
|
|
281
|
+
},
|
|
282
|
+
"dry-run": {
|
|
283
|
+
kind: "boolean",
|
|
284
|
+
description: "Run probes and report results without writing envelope files.",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
execute: runRfcVerificationRefresh,
|
|
288
|
+
});
|
|
289
|
+
|
|
262
290
|
// ── rfc.dna.trace.validate ───────────────────────────────────────────────
|
|
263
291
|
registry.registerCommand({
|
|
264
292
|
name: "rfc.dna.trace.validate",
|