@warpgogol/forge 2.21.6 → 2.21.7
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
package/os/rfc/acceptance.ts
CHANGED
|
@@ -14,6 +14,7 @@ prose checklist.
|
|
|
14
14
|
</MODULE_CONTRACT>
|
|
15
15
|
<CHANGE_SUMMARY>
|
|
16
16
|
<item>RFC-0268: initial implementation.</item>
|
|
17
|
+
<item>RFC-0998: added test and json-schema probe kinds — spawnVitest helper, Ajv validation, shape validation.</item>
|
|
17
18
|
</CHANGE_SUMMARY>
|
|
18
19
|
*/
|
|
19
20
|
|
|
@@ -21,6 +22,7 @@ import { spawn } from "node:child_process";
|
|
|
21
22
|
import { stat, readFile } from "node:fs/promises";
|
|
22
23
|
import path from "node:path";
|
|
23
24
|
import { parse as yamlParse } from "yaml";
|
|
25
|
+
import { Ajv } from "ajv";
|
|
24
26
|
import type {
|
|
25
27
|
Diagnostic,
|
|
26
28
|
ForgeCommandInput,
|
|
@@ -28,9 +30,15 @@ import type {
|
|
|
28
30
|
ForgeRuntimeContext,
|
|
29
31
|
CommandRegistry,
|
|
30
32
|
} from "../../src/types.ts";
|
|
31
|
-
import type {
|
|
32
|
-
|
|
33
|
+
import type {
|
|
34
|
+
AcceptanceProbe,
|
|
35
|
+
ProbeResult,
|
|
36
|
+
RfcAcceptanceRunResult,
|
|
37
|
+
ProbeCoverageReport,
|
|
38
|
+
} from "./types.ts";
|
|
39
|
+
import { RFC_DIR, RFC_PROBE_BINDING_CUTOFF } from "./types.ts";
|
|
33
40
|
import { listRfcFiles, readAndParseRfc } from "./frontmatter-io.ts";
|
|
41
|
+
import { evaluateAcceptanceCriteria, computeProbeCoverage } from "./handlers/validate-rules.ts";
|
|
34
42
|
|
|
35
43
|
const RUN_PROBE_ALLOWED_PREFIX = "werkstatt ";
|
|
36
44
|
|
|
@@ -138,10 +146,40 @@ export function validateAcceptanceShape(value: unknown): AcceptanceShapeIssue[]
|
|
|
138
146
|
}
|
|
139
147
|
break;
|
|
140
148
|
}
|
|
149
|
+
case "test": {
|
|
150
|
+
const file = (entry as Record<string, unknown>)["file"];
|
|
151
|
+
if (typeof file !== "string") {
|
|
152
|
+
issues.push({ index, message: 'probe "test" requires a string "file"' });
|
|
153
|
+
}
|
|
154
|
+
const testName = (entry as Record<string, unknown>)["testName"];
|
|
155
|
+
if (testName !== undefined && typeof testName !== "string") {
|
|
156
|
+
issues.push({ index, message: 'probe "test" testName must be a string if present' });
|
|
157
|
+
}
|
|
158
|
+
const expect = (entry as Record<string, unknown>)["expect"];
|
|
159
|
+
if (
|
|
160
|
+
!expect ||
|
|
161
|
+
typeof expect !== "object" ||
|
|
162
|
+
typeof (expect as Record<string, unknown>)["exitCode"] !== "number"
|
|
163
|
+
) {
|
|
164
|
+
issues.push({ index, message: 'probe "test" requires expect: { exitCode: <number> }' });
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "json-schema": {
|
|
169
|
+
const artifact = (entry as Record<string, unknown>)["artifact"];
|
|
170
|
+
if (typeof artifact !== "string") {
|
|
171
|
+
issues.push({ index, message: 'probe "json-schema" requires a string "artifact"' });
|
|
172
|
+
}
|
|
173
|
+
const schemaInline = (entry as Record<string, unknown>)["schemaInline"];
|
|
174
|
+
if (!schemaInline || typeof schemaInline !== "object") {
|
|
175
|
+
issues.push({ index, message: 'probe "json-schema" requires an object "schemaInline"' });
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
141
179
|
default:
|
|
142
180
|
issues.push({
|
|
143
181
|
index,
|
|
144
|
-
message: `unknown probe kind "${String(probe)}" — expected one of: run, file-exists, file-contains, command-registered, page`,
|
|
182
|
+
message: `unknown probe kind "${String(probe)}" — expected one of: run, file-exists, file-contains, command-registered, page, test, json-schema`,
|
|
145
183
|
});
|
|
146
184
|
}
|
|
147
185
|
});
|
|
@@ -189,6 +227,45 @@ async function spawnSiteKernel(
|
|
|
189
227
|
});
|
|
190
228
|
}
|
|
191
229
|
|
|
230
|
+
async function spawnVitest(
|
|
231
|
+
workspaceRoot: string,
|
|
232
|
+
file: string,
|
|
233
|
+
testName?: string,
|
|
234
|
+
): Promise<{ exitCode: number | null; timedOut: boolean }> {
|
|
235
|
+
const args = ["exec", "vitest", "run", file];
|
|
236
|
+
if (testName) {
|
|
237
|
+
args.push("-t", testName);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
const child = spawn("pnpm", args, {
|
|
242
|
+
cwd: workspaceRoot,
|
|
243
|
+
stdio: "ignore",
|
|
244
|
+
});
|
|
245
|
+
let settled = false;
|
|
246
|
+
const timer = setTimeout(() => {
|
|
247
|
+
if (settled) return;
|
|
248
|
+
settled = true;
|
|
249
|
+
child.kill();
|
|
250
|
+
resolve({ exitCode: null, timedOut: true });
|
|
251
|
+
}, RUN_PROBE_TIMEOUT_MS);
|
|
252
|
+
timer.unref?.();
|
|
253
|
+
|
|
254
|
+
child.on("close", (code) => {
|
|
255
|
+
if (settled) return;
|
|
256
|
+
settled = true;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
resolve({ exitCode: code, timedOut: false });
|
|
259
|
+
});
|
|
260
|
+
child.on("error", () => {
|
|
261
|
+
if (settled) return;
|
|
262
|
+
settled = true;
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
resolve({ exitCode: null, timedOut: false });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
192
269
|
/** Executes a single probe. Pure aside from its declared filesystem/process/registry effects. */
|
|
193
270
|
export async function runProbe(
|
|
194
271
|
probe: AcceptanceProbe,
|
|
@@ -247,6 +324,48 @@ export async function runProbe(
|
|
|
247
324
|
detail: "page probe skipped — run qa.independent.run against a built dist",
|
|
248
325
|
};
|
|
249
326
|
}
|
|
327
|
+
case "test": {
|
|
328
|
+
const { exitCode, timedOut } = await spawnVitest(workspaceRoot, probe.file, probe.testName);
|
|
329
|
+
if (timedOut) {
|
|
330
|
+
return { probe, ok: false, detail: `timed out after ${RUN_PROBE_TIMEOUT_MS}ms` };
|
|
331
|
+
}
|
|
332
|
+
const ok = exitCode === probe.expect.exitCode;
|
|
333
|
+
return { probe, ok, detail: `exitCode=${exitCode} (expected ${probe.expect.exitCode})` };
|
|
334
|
+
}
|
|
335
|
+
case "json-schema": {
|
|
336
|
+
const artifactPath = path.join(workspaceRoot, probe.artifact);
|
|
337
|
+
let raw: string;
|
|
338
|
+
try {
|
|
339
|
+
raw = await readFile(artifactPath, "utf8");
|
|
340
|
+
} catch {
|
|
341
|
+
return { probe, ok: false, detail: "artifact file not found" };
|
|
342
|
+
}
|
|
343
|
+
let parsed: unknown;
|
|
344
|
+
const ext = path.extname(probe.artifact).toLowerCase();
|
|
345
|
+
try {
|
|
346
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
347
|
+
parsed = yamlParse(raw);
|
|
348
|
+
} else {
|
|
349
|
+
parsed = JSON.parse(raw);
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
return { probe, ok: false, detail: "artifact file could not be parsed" };
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const ajv = new Ajv({ allErrors: false });
|
|
356
|
+
const validate = ajv.compile(probe.schemaInline);
|
|
357
|
+
const valid = validate(parsed);
|
|
358
|
+
if (valid) {
|
|
359
|
+
return { probe, ok: true, detail: "schema valid" };
|
|
360
|
+
}
|
|
361
|
+
const firstError = validate.errors?.[0];
|
|
362
|
+
const errorPath = firstError?.instancePath || "(root)";
|
|
363
|
+
const errorMessage = firstError?.message || "validation failed";
|
|
364
|
+
return { probe, ok: false, detail: `Ajv error at ${errorPath}: ${errorMessage}` };
|
|
365
|
+
} catch {
|
|
366
|
+
return { probe, ok: false, detail: "schema compilation failed" };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
250
369
|
}
|
|
251
370
|
}
|
|
252
371
|
|
|
@@ -330,7 +449,17 @@ export async function runRfcAcceptanceRun(
|
|
|
330
449
|
message: `${pageProbeCount} page probe(s) skipped — run \`qa.independent.run --site <app>\` against a built dist.`,
|
|
331
450
|
});
|
|
332
451
|
}
|
|
333
|
-
|
|
452
|
+
|
|
453
|
+
let rfcCoverage: ProbeCoverageReport | undefined;
|
|
454
|
+
const createdAt = String(fm["createdAt"] ?? "");
|
|
455
|
+
const isArchived = fileName.startsWith("archive/");
|
|
456
|
+
if (createdAt >= RFC_PROBE_BINDING_CUTOFF && !isArchived) {
|
|
457
|
+
const parsedBody = parsedFile.parsed.body;
|
|
458
|
+
const criteriaEval = evaluateAcceptanceCriteria(parsedBody);
|
|
459
|
+
rfcCoverage = computeProbeCoverage(criteriaEval.criterionIds, acceptance);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
results.push({ rfcId, probeResults, coverage: rfcCoverage });
|
|
334
463
|
}
|
|
335
464
|
|
|
336
465
|
const failedCount = diagnostics.filter((d) => d.severity === "error").length;
|
|
@@ -15,6 +15,7 @@ verification evidence, and atomically mutates RFC frontmatter.
|
|
|
15
15
|
<item>RFC-0476: initial implementation.</item>
|
|
16
16
|
<item>RFC-0756: auto-detect implementation commit when --implementation-commit is omitted.</item>
|
|
17
17
|
<item>RFC-0795: add RFC-IMP-07 dependsOn dependency gate — blocks stamping when any dependsOn entry is not implemented.</item>
|
|
18
|
+
<item>RFC-0997: add RFC-IMP-08 minimum-one-probe gate — blocks stamping for post-cutoff architecture/contract/command RFCs with no acceptance probes.</item>
|
|
18
19
|
</CHANGE_SUMMARY>
|
|
19
20
|
*/
|
|
20
21
|
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
} from "../frontmatter-io.ts";
|
|
34
35
|
import { evaluateAcceptanceCriteria } from "./validate-rules.ts";
|
|
35
36
|
import { toIsoDate } from "./shared.ts";
|
|
36
|
-
import { RFC_DIR, RFC_METADATA_CUTOFF } from "../types.ts";
|
|
37
|
+
import { RFC_DIR, RFC_METADATA_CUTOFF, RFC_PROBE_BINDING_CUTOFF } from "../types.ts";
|
|
37
38
|
import type { RfcStatus, RfcImplementStampViolation, RfcImplementStampResult } from "../types.ts";
|
|
38
39
|
import type {
|
|
39
40
|
ForgeCommandInput,
|
|
@@ -399,6 +400,18 @@ export async function runRfcImplementStamp(
|
|
|
399
400
|
}
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
// ── RFC-IMP-08: minimum-one-probe gate (RFC-0997) ─────────────────────────
|
|
404
|
+
// Post-cutoff architecture/contract/command RFCs must declare at least one
|
|
405
|
+
// acceptance probe. Policy and deprecation kinds are exempt.
|
|
406
|
+
const rfcKind = String(fm["kind"] ?? "");
|
|
407
|
+
const probeGateKinds = new Set(["architecture", "contract", "command"]);
|
|
408
|
+
if (createdAtStr >= RFC_PROBE_BINDING_CUTOFF && probeGateKinds.has(rfcKind) && !hasProbes) {
|
|
409
|
+
violations.push({
|
|
410
|
+
rule: "RFC-IMP-08",
|
|
411
|
+
message: `RFC ${targetId} (kind: ${rfcKind}) has no acceptance probes. Post-cutoff architecture/contract/command RFCs require at least one probe (RFC-0997). See RFC-0996 for the authoring standard.`,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
402
415
|
// If any violations found, return without mutation
|
|
403
416
|
if (violations.length > 0) {
|
|
404
417
|
return stampFailResult(violations, isDryRun, outputFormat, logger);
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
evaluateAcceptanceCriteria,
|
|
4
|
+
computeProbeCoverage,
|
|
5
|
+
type AddViolationFn,
|
|
6
|
+
validateSingleRfc,
|
|
7
|
+
} from "./validate-rules.ts";
|
|
8
|
+
import type { ParsedRfc } from "../frontmatter-io.ts";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
const testWorkspace = join(tmpdir(), "test-workspace-rfc0997");
|
|
13
|
+
|
|
14
|
+
function makeParsed(
|
|
15
|
+
status: string,
|
|
16
|
+
body: string,
|
|
17
|
+
extraFm: Record<string, unknown> = {},
|
|
18
|
+
): ParsedRfc {
|
|
19
|
+
return {
|
|
20
|
+
frontmatter: {
|
|
21
|
+
id: "RFC-9999",
|
|
22
|
+
title: "Test RFC",
|
|
23
|
+
status,
|
|
24
|
+
kind: "command",
|
|
25
|
+
scope: "workspace",
|
|
26
|
+
owners: ["architecture"],
|
|
27
|
+
createdAt: "2026-09-01",
|
|
28
|
+
updatedAt: "2026-09-01",
|
|
29
|
+
...extraFm,
|
|
30
|
+
},
|
|
31
|
+
body,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BASE_BODY = `
|
|
36
|
+
# RFC-9999: Test RFC
|
|
37
|
+
|
|
38
|
+
## Context
|
|
39
|
+
|
|
40
|
+
Test context.
|
|
41
|
+
|
|
42
|
+
## Problem
|
|
43
|
+
|
|
44
|
+
Test problem.
|
|
45
|
+
|
|
46
|
+
## Decision
|
|
47
|
+
|
|
48
|
+
Test decision.
|
|
49
|
+
|
|
50
|
+
## Architectural fit
|
|
51
|
+
|
|
52
|
+
Test fit.
|
|
53
|
+
|
|
54
|
+
## Design
|
|
55
|
+
|
|
56
|
+
### CLI surface
|
|
57
|
+
|
|
58
|
+
Test CLI.
|
|
59
|
+
|
|
60
|
+
### TypeScript contracts
|
|
61
|
+
|
|
62
|
+
Test types.
|
|
63
|
+
|
|
64
|
+
### File system responsibilities
|
|
65
|
+
|
|
66
|
+
| Path | Role |
|
|
67
|
+
|---|---|
|
|
68
|
+
| \`test.ts\` | test |
|
|
69
|
+
|
|
70
|
+
### Output format
|
|
71
|
+
|
|
72
|
+
Test output.
|
|
73
|
+
|
|
74
|
+
### Failure modes
|
|
75
|
+
|
|
76
|
+
Test failures.
|
|
77
|
+
|
|
78
|
+
## Rollout
|
|
79
|
+
|
|
80
|
+
Test rollout.
|
|
81
|
+
|
|
82
|
+
## Alternatives considered
|
|
83
|
+
|
|
84
|
+
Test alternatives.
|
|
85
|
+
|
|
86
|
+
## Risks
|
|
87
|
+
|
|
88
|
+
Test risks.
|
|
89
|
+
|
|
90
|
+
## Acceptance criteria
|
|
91
|
+
|
|
92
|
+
ACCEPTANCE_HERE
|
|
93
|
+
|
|
94
|
+
## Implementation notes for agents
|
|
95
|
+
|
|
96
|
+
Test notes.
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
function makeViolationsCollector(): {
|
|
100
|
+
add: AddViolationFn;
|
|
101
|
+
violations: { rfcId: string; rule: string; message: string; severity: string }[];
|
|
102
|
+
} {
|
|
103
|
+
const violations: { rfcId: string; rule: string; message: string; severity: string }[] = [];
|
|
104
|
+
const add: AddViolationFn = (rfcId, _file, rule, message, severity = "error") => {
|
|
105
|
+
violations.push({ rfcId, rule, message, severity });
|
|
106
|
+
};
|
|
107
|
+
return { add, violations };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runValidate(
|
|
111
|
+
parsed: ParsedRfc,
|
|
112
|
+
): Promise<{ rfcId: string; rule: string; message: string; severity: string }[]> {
|
|
113
|
+
const { add, violations } = makeViolationsCollector();
|
|
114
|
+
await validateSingleRfc(
|
|
115
|
+
"rfc-9999-test.md",
|
|
116
|
+
parsed,
|
|
117
|
+
new Map(),
|
|
118
|
+
new Map(),
|
|
119
|
+
new Set(),
|
|
120
|
+
new Set(),
|
|
121
|
+
new Set(Object.keys(parsed.frontmatter)),
|
|
122
|
+
testWorkspace,
|
|
123
|
+
add,
|
|
124
|
+
);
|
|
125
|
+
return violations;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function filterRule(
|
|
129
|
+
violations: { rfcId: string; rule: string; message: string; severity: string }[],
|
|
130
|
+
rule: string,
|
|
131
|
+
): { rfcId: string; rule: string; message: string; severity: string }[] {
|
|
132
|
+
return violations.filter((v) => v.rule === rule);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── evaluateAcceptanceCriteria: AC-N parsing ──────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe("evaluateAcceptanceCriteria: AC-N id parsing (RFC-0997)", () => {
|
|
138
|
+
test("parses AC-N ids from checklist lines", () => {
|
|
139
|
+
const body = BASE_BODY.replace(
|
|
140
|
+
"ACCEPTANCE_HERE",
|
|
141
|
+
"- [x] AC-1: first criterion (evidence: test.ts:1)\n- [ ] AC-2: second criterion\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
142
|
+
);
|
|
143
|
+
const result = evaluateAcceptanceCriteria(body);
|
|
144
|
+
expect(result.criterionIds).toEqual(["AC-1", "AC-2", "AC-3"]);
|
|
145
|
+
expect(result.duplicateCriterionIds).toEqual([]);
|
|
146
|
+
expect(result.linesWithoutId).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("detects duplicate AC-N ids", () => {
|
|
150
|
+
const body = BASE_BODY.replace(
|
|
151
|
+
"ACCEPTANCE_HERE",
|
|
152
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] AC-1: duplicate\n- [x] AC-2: third (evidence: test.ts:2)",
|
|
153
|
+
);
|
|
154
|
+
const result = evaluateAcceptanceCriteria(body);
|
|
155
|
+
expect(result.criterionIds).toEqual(["AC-1", "AC-2"]);
|
|
156
|
+
expect(result.duplicateCriterionIds).toEqual(["AC-1"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("detects checklist lines without AC-N prefix", () => {
|
|
160
|
+
const body = BASE_BODY.replace(
|
|
161
|
+
"ACCEPTANCE_HERE",
|
|
162
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] no id here\n- [x] AC-2: third (evidence: test.ts:2)",
|
|
163
|
+
);
|
|
164
|
+
const result = evaluateAcceptanceCriteria(body);
|
|
165
|
+
expect(result.linesWithoutId).toEqual(["- [ ] no id here"]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─── computeProbeCoverage ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe("computeProbeCoverage (RFC-0997)", () => {
|
|
172
|
+
test("full coverage when all criteria have probes", () => {
|
|
173
|
+
const ids = ["AC-1", "AC-2", "AC-3"];
|
|
174
|
+
const acceptance = [
|
|
175
|
+
{ probe: "file-exists", path: "a.ts", criterion: "AC-1" },
|
|
176
|
+
{ probe: "file-exists", path: "b.ts", criterion: "AC-2" },
|
|
177
|
+
{ probe: "file-exists", path: "c.ts", criterion: "AC-3" },
|
|
178
|
+
];
|
|
179
|
+
const report = computeProbeCoverage(ids, acceptance);
|
|
180
|
+
expect(report.totalCriteria).toBe(3);
|
|
181
|
+
expect(report.probeBackedCriteria).toBe(3);
|
|
182
|
+
expect(report.coverageRatio).toBe(1);
|
|
183
|
+
expect(report.uncoveredCriteria).toEqual([]);
|
|
184
|
+
expect(report.unboundProbes).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("partial coverage when some criteria lack probes", () => {
|
|
188
|
+
const ids = ["AC-1", "AC-2", "AC-3"];
|
|
189
|
+
const acceptance = [
|
|
190
|
+
{ probe: "file-exists", path: "a.ts", criterion: "AC-1" },
|
|
191
|
+
{ probe: "file-exists", path: "b.ts", criterion: "AC-2" },
|
|
192
|
+
];
|
|
193
|
+
const report = computeProbeCoverage(ids, acceptance);
|
|
194
|
+
expect(report.totalCriteria).toBe(3);
|
|
195
|
+
expect(report.probeBackedCriteria).toBe(2);
|
|
196
|
+
expect(report.coverageRatio).toBeCloseTo(0.667, 2);
|
|
197
|
+
expect(report.uncoveredCriteria).toEqual(["AC-3"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("unbound probe references nonexistent criterion", () => {
|
|
201
|
+
const ids = ["AC-1"];
|
|
202
|
+
const acceptance = [
|
|
203
|
+
{ probe: "file-exists", path: "a.ts", criterion: "AC-1" },
|
|
204
|
+
{ probe: "file-exists", path: "b.ts", criterion: "AC-99" },
|
|
205
|
+
];
|
|
206
|
+
const report = computeProbeCoverage(ids, acceptance);
|
|
207
|
+
expect(report.unboundProbes).toEqual(["AC-99"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("probe without criterion field is unbound", () => {
|
|
211
|
+
const ids = ["AC-1"];
|
|
212
|
+
const acceptance = [{ probe: "file-exists", path: "a.ts" }];
|
|
213
|
+
const report = computeProbeCoverage(ids, acceptance);
|
|
214
|
+
expect(report.unboundProbes).toEqual(["(missing)"]);
|
|
215
|
+
expect(report.probeBackedCriteria).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("zero criteria and zero probes gives ratio 0", () => {
|
|
219
|
+
const report = computeProbeCoverage([], []);
|
|
220
|
+
expect(report.totalCriteria).toBe(0);
|
|
221
|
+
expect(report.coverageRatio).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ─── V-35: probe→criterion referential integrity ───────────────────────────
|
|
226
|
+
|
|
227
|
+
describe("V-35: probe→criterion referential integrity (RFC-0997)", () => {
|
|
228
|
+
test("post-cutoff probe without criterion fires V-35", async () => {
|
|
229
|
+
const body = BASE_BODY.replace(
|
|
230
|
+
"ACCEPTANCE_HERE",
|
|
231
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
232
|
+
);
|
|
233
|
+
const parsed = makeParsed("accepted", body, {
|
|
234
|
+
acceptance: [{ probe: "file-exists", path: "a.ts" }],
|
|
235
|
+
});
|
|
236
|
+
const violations = await runValidate(parsed);
|
|
237
|
+
const v35 = filterRule(violations, "V-35");
|
|
238
|
+
expect(v35.length).toBeGreaterThanOrEqual(1);
|
|
239
|
+
expect(v35[0]!.message).toContain('lacks required "criterion"');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("post-cutoff probe with nonexistent criterion fires V-35", async () => {
|
|
243
|
+
const body = BASE_BODY.replace(
|
|
244
|
+
"ACCEPTANCE_HERE",
|
|
245
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
246
|
+
);
|
|
247
|
+
const parsed = makeParsed("accepted", body, {
|
|
248
|
+
acceptance: [{ probe: "file-exists", path: "a.ts", criterion: "AC-99" }],
|
|
249
|
+
});
|
|
250
|
+
const violations = await runValidate(parsed);
|
|
251
|
+
const v35 = filterRule(violations, "V-35");
|
|
252
|
+
expect(v35.length).toBeGreaterThanOrEqual(1);
|
|
253
|
+
expect(v35[0]!.message).toContain("does not exist");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("post-cutoff probe with valid criterion does NOT fire V-35", async () => {
|
|
257
|
+
const body = BASE_BODY.replace(
|
|
258
|
+
"ACCEPTANCE_HERE",
|
|
259
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
260
|
+
);
|
|
261
|
+
const parsed = makeParsed("accepted", body, {
|
|
262
|
+
acceptance: [{ probe: "file-exists", path: "a.ts", criterion: "AC-1" }],
|
|
263
|
+
});
|
|
264
|
+
const violations = await runValidate(parsed);
|
|
265
|
+
const v35 = filterRule(violations, "V-35");
|
|
266
|
+
expect(v35).toHaveLength(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("pre-cutoff probe without criterion does NOT fire V-35", async () => {
|
|
270
|
+
const body = BASE_BODY.replace(
|
|
271
|
+
"ACCEPTANCE_HERE",
|
|
272
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
273
|
+
);
|
|
274
|
+
const parsed = makeParsed("accepted", body, {
|
|
275
|
+
createdAt: "2026-01-01",
|
|
276
|
+
acceptance: [{ probe: "file-exists", path: "a.ts" }],
|
|
277
|
+
});
|
|
278
|
+
const violations = await runValidate(parsed);
|
|
279
|
+
const v35 = filterRule(violations, "V-35");
|
|
280
|
+
expect(v35).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ─── V-36: criterion identifier discipline ─────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe("V-36: criterion identifier discipline (RFC-0997)", () => {
|
|
287
|
+
test("post-cutoff line without AC-N fires V-36", async () => {
|
|
288
|
+
const body = BASE_BODY.replace(
|
|
289
|
+
"ACCEPTANCE_HERE",
|
|
290
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] no id here\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
291
|
+
);
|
|
292
|
+
const parsed = makeParsed("accepted", body);
|
|
293
|
+
const violations = await runValidate(parsed);
|
|
294
|
+
const v36 = filterRule(violations, "V-36");
|
|
295
|
+
expect(v36.length).toBeGreaterThanOrEqual(1);
|
|
296
|
+
expect(v36[0]!.message).toContain("lacks");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("post-cutoff duplicate AC-N fires V-36", async () => {
|
|
300
|
+
const body = BASE_BODY.replace(
|
|
301
|
+
"ACCEPTANCE_HERE",
|
|
302
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] AC-1: dup\n- [x] AC-2: third (evidence: test.ts:2)",
|
|
303
|
+
);
|
|
304
|
+
const parsed = makeParsed("accepted", body);
|
|
305
|
+
const violations = await runValidate(parsed);
|
|
306
|
+
const v36 = filterRule(violations, "V-36");
|
|
307
|
+
expect(v36.length).toBeGreaterThanOrEqual(1);
|
|
308
|
+
expect(v36[0]!.message).toContain("duplicate");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("pre-cutoff line without AC-N does NOT fire V-36", async () => {
|
|
312
|
+
const body = BASE_BODY.replace(
|
|
313
|
+
"ACCEPTANCE_HERE",
|
|
314
|
+
"- [x] AC-1: first (evidence: test.ts:1)\n- [ ] no id here\n- [x] AC-3: third (evidence: test.ts:2)",
|
|
315
|
+
);
|
|
316
|
+
const parsed = makeParsed("accepted", body, { createdAt: "2026-01-01" });
|
|
317
|
+
const violations = await runValidate(parsed);
|
|
318
|
+
const v36 = filterRule(violations, "V-36");
|
|
319
|
+
expect(v36).toHaveLength(0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ─── V-37: evidence mechanism validity ─────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
describe("V-37: evidence mechanism validity (RFC-0997)", () => {
|
|
326
|
+
test("post-cutoff probe:AC-N with no probe fires V-37", async () => {
|
|
327
|
+
const body = BASE_BODY.replace(
|
|
328
|
+
"ACCEPTANCE_HERE",
|
|
329
|
+
"- [x] AC-1: first (evidence: probe:AC-1)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: probe:AC-3)",
|
|
330
|
+
);
|
|
331
|
+
const parsed = makeParsed("accepted", body, {
|
|
332
|
+
acceptance: [],
|
|
333
|
+
});
|
|
334
|
+
const violations = await runValidate(parsed);
|
|
335
|
+
const v37 = filterRule(violations, "V-37");
|
|
336
|
+
expect(v37.length).toBeGreaterThanOrEqual(1);
|
|
337
|
+
expect(v37[0]!.message).toContain("no bound probe");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("post-cutoff probe:AC-N referencing nonexistent criterion fires V-37", async () => {
|
|
341
|
+
const body = BASE_BODY.replace(
|
|
342
|
+
"ACCEPTANCE_HERE",
|
|
343
|
+
"- [x] AC-1: first (evidence: probe:AC-99)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: probe:AC-3)",
|
|
344
|
+
);
|
|
345
|
+
const parsed = makeParsed("accepted", body, {
|
|
346
|
+
acceptance: [],
|
|
347
|
+
});
|
|
348
|
+
const violations = await runValidate(parsed);
|
|
349
|
+
const v37 = filterRule(violations, "V-37");
|
|
350
|
+
expect(v37.length).toBeGreaterThanOrEqual(1);
|
|
351
|
+
expect(v37[0]!.message).toContain("does not exist");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("post-cutoff test:path with missing file fires V-37", async () => {
|
|
355
|
+
const body = BASE_BODY.replace(
|
|
356
|
+
"ACCEPTANCE_HERE",
|
|
357
|
+
"- [x] AC-1: first (evidence: test:nonexistent/file.ts)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: test:nonexistent/file2.ts)",
|
|
358
|
+
);
|
|
359
|
+
const parsed = makeParsed("accepted", body, {
|
|
360
|
+
acceptance: [],
|
|
361
|
+
});
|
|
362
|
+
const violations = await runValidate(parsed);
|
|
363
|
+
const v37 = filterRule(violations, "V-37");
|
|
364
|
+
expect(v37.length).toBeGreaterThanOrEqual(1);
|
|
365
|
+
expect(v37[0]!.message).toContain("does not exist");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("pre-cutoff does NOT fire V-37", async () => {
|
|
369
|
+
const body = BASE_BODY.replace(
|
|
370
|
+
"ACCEPTANCE_HERE",
|
|
371
|
+
"- [x] AC-1: first (evidence: probe:AC-99)\n- [ ] AC-2: second\n- [x] AC-3: third (evidence: probe:AC-99)",
|
|
372
|
+
);
|
|
373
|
+
const parsed = makeParsed("accepted", body, {
|
|
374
|
+
createdAt: "2026-01-01",
|
|
375
|
+
acceptance: [],
|
|
376
|
+
});
|
|
377
|
+
const violations = await runValidate(parsed);
|
|
378
|
+
const v37 = filterRule(violations, "V-37");
|
|
379
|
+
expect(v37).toHaveLength(0);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ─── V-37: null-safety guard (no acceptance section) ───────────────────────
|
|
384
|
+
|
|
385
|
+
describe("V-37: null-safety when no acceptance criteria section (RFC-0997)", () => {
|
|
386
|
+
test("post-cutoff RFC with no acceptance section does not crash", async () => {
|
|
387
|
+
const body = BASE_BODY.replace("## Acceptance criteria\n\nACCEPTANCE_HERE\n", "");
|
|
388
|
+
const parsed = makeParsed("accepted", body, {
|
|
389
|
+
acceptance: [{ probe: "file-exists", path: "a.ts", criterion: "AC-1" }],
|
|
390
|
+
});
|
|
391
|
+
const violations = await runValidate(parsed);
|
|
392
|
+
expect(violations).toBeDefined();
|
|
393
|
+
});
|
|
394
|
+
});
|