@planu/cli 5.3.3 → 5.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/engine/autopilot/action-registry.js +20 -2
- package/dist/engine/constitution/sdd-rules-registry.js +8 -4
- package/dist/engine/drift/violation-resolver.js +30 -8
- package/dist/engine/evidence-gates/evidence-skeletons.js +9 -4
- package/dist/engine/execution/validate-job-executor.js +25 -10
- package/dist/engine/handoff-packager.js +10 -3
- package/dist/engine/implementation-contract/common.d.ts +13 -1
- package/dist/engine/implementation-contract/common.js +19 -3
- package/dist/engine/implementation-contract/evaluator.js +116 -35
- package/dist/engine/implementation-contract/renderer.js +62 -31
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/reconcile/apply-changes.d.ts +0 -12
- package/dist/engine/reconcile/apply-changes.js +11 -33
- package/dist/engine/reconcile/verify-write.js +2 -3
- package/dist/engine/rules-reconciler.js +21 -2
- package/dist/engine/spec-format/acceptance-criteria.js +7 -1
- package/dist/engine/spec-format/unified-spec-builder.js +32 -8
- package/dist/engine/spec-generator/fallback-generator.js +5 -3
- package/dist/engine/spec-quality/generic-output-gate.d.ts +7 -1
- package/dist/engine/spec-quality/generic-output-gate.js +199 -8
- package/dist/engine/spec-quality-scorer.js +9 -5
- package/dist/engine/universal-rules/catalog.js +6 -0
- package/dist/engine/universal-rules/rules/agent-teams.js +0 -2
- package/dist/engine/universal-rules/rules/planu-approval-gates.js +0 -1
- package/dist/engine/universal-rules/rules/planu-bdd-criteria.js +0 -1
- package/dist/engine/universal-rules/rules/planu-clean-code-no-comments.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-clean-code-no-comments.js +30 -0
- package/dist/engine/universal-rules/rules/planu-debate-review.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-debate-review.js +38 -0
- package/dist/engine/universal-rules/rules/planu-dogfood-bugs.js +0 -2
- package/dist/engine/universal-rules/rules/planu-english-specs.js +0 -1
- package/dist/engine/universal-rules/rules/planu-minimal-change.js +0 -2
- package/dist/engine/universal-rules/rules/planu-modes.js +0 -2
- package/dist/engine/universal-rules/rules/planu-release-policy.js +0 -1
- package/dist/engine/universal-rules/rules/planu-revert-proof-tests.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-revert-proof-tests.js +34 -0
- package/dist/engine/universal-rules/rules/planu-sdd-model-routing.js +0 -1
- package/dist/engine/universal-rules/rules/planu-workflow.js +0 -2
- package/dist/engine/validator/validation-report-writer.js +1 -1
- package/dist/engine/workers/handlers/auto-drift.js +22 -8
- package/dist/tools/create-spec.js +5 -0
- package/dist/tools/reconcile-spec.d.ts +1 -1
- package/dist/tools/reconcile-spec.js +303 -178
- package/dist/tools/update-status/dod-gates.js +3 -3
- package/dist/tools/update-status/evidence-gate.js +10 -17
- package/dist/tools/update-status/transition-guard.js +26 -14
- package/dist/types/reconcile.d.ts +5 -17
- package/dist/types/reconcile.js +1 -7
- package/dist/types/spec-quality.d.ts +6 -1
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## [5.3.5] - 2026-08-06
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(tests): make the spec-write guard test independent of git HEAD
|
|
5
|
+
- fix(lifecycle): close done-gate bypass, report digest drift and ship debate rules
|
|
6
|
+
- fix(reconcile): harden audit-cell escaping and fail-closed rollback (SPEC-1250)
|
|
7
|
+
- fix(spec-format): preserve wrapped BDD continuation lines in criterion identities (SPEC-1253)
|
|
8
|
+
|
|
9
|
+
### Chores
|
|
10
|
+
- chore(planu): close SPEC-1250 with debate evidence, file SPEC-1424
|
|
11
|
+
- chore(planu): SPEC-1253 done with debate evidence
|
|
12
|
+
- chore(planu): file SPEC-1423 (done-gate revalidate reprocessing)
|
|
13
|
+
- chore(planu): file SPEC-1421 and SPEC-1422 from client dogfood report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [5.3.4] - 2026-08-06
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
- fix(deps): override js-yaml to patched versions for CVE-2026-59870
|
|
20
|
+
- fix(spec-format): keep rendered criteria BDD-executable after canonical parsing
|
|
21
|
+
- fix(spec-format): resolve rendered criteria through the canonical parser (SPEC-1410)
|
|
22
|
+
- fix(spec-quality): render and count every acceptance criterion (SPEC-1410)
|
|
23
|
+
- fix(planu): point SPEC-1405 verification map at the real test filename
|
|
24
|
+
- fix(spec-quality): stop shipping template filler and self-certifying spec sections (SPEC-1406)
|
|
25
|
+
- fix(drift): make follow-up spec id deterministic per parent spec (SPEC-1405)
|
|
26
|
+
- fix(drift): emit one drift event per project instead of one per spec (SPEC-1405)
|
|
27
|
+
|
|
28
|
+
### Chores
|
|
29
|
+
- chore(planu): SPEC-1405 and SPEC-1406 done with debate evidence
|
|
30
|
+
- chore(planu): SPEC-1405 and SPEC-1406 implementing with packaged handoffs
|
|
31
|
+
- chore(planu): approve SPEC-1405 and SPEC-1406 with debate evidence, file SPEC-1410..1411
|
|
32
|
+
- chore(planu): file SPEC-1401..1409 from deep flow audit
|
|
33
|
+
- chore(planu): SPEC-1396 done with debate evidence, file SPEC-1397..1400
|
|
34
|
+
|
|
35
|
+
|
|
1
36
|
## [5.3.3] - 2026-08-06
|
|
2
37
|
|
|
3
38
|
### Bug Fixes
|
|
@@ -219,7 +219,17 @@ const ACTION_HANDLERS = {
|
|
|
219
219
|
resolve_drift_violations: wrapHandler('resolve_drift_violations', async (ctx) => {
|
|
220
220
|
const { resolveDriftViolations } = await import('../drift/violation-resolver.js');
|
|
221
221
|
const result = (await resolveDriftViolations(ctx.projectPath, ctx.projectId));
|
|
222
|
-
|
|
222
|
+
// DriftResolutionResult exposes `resolved` as an array (see types/health.ts);
|
|
223
|
+
// safeCount() returns its length. There is no `resolvedCount` field.
|
|
224
|
+
const resolved = safeCount(result, 'resolved');
|
|
225
|
+
const manualReview = safeCount(result, 'manualReview');
|
|
226
|
+
if (manualReview > 0) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
summary: `Resolved ${resolved} drift violations, ${manualReview} need manual review`,
|
|
230
|
+
durationMs: 0,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
223
233
|
return {
|
|
224
234
|
success: true,
|
|
225
235
|
summary: `Resolved ${resolved} drift violations`,
|
|
@@ -228,13 +238,21 @@ const ACTION_HANDLERS = {
|
|
|
228
238
|
}),
|
|
229
239
|
log_lesson: wrapHandler('log_lesson', async (ctx) => {
|
|
230
240
|
const { addLesson } = await import('../../storage/lessons-store.js');
|
|
241
|
+
// drift:detected events (auto-drift.ts) are project-wide and carry no
|
|
242
|
+
// ctx.specId; fall back to the payload's specIds so the lesson stays
|
|
243
|
+
// attributed to the drifted specs instead of going generic.
|
|
244
|
+
const payloadSpecIds = ctx.payload?.specIds;
|
|
245
|
+
const attributedSpecIds = Array.isArray(payloadSpecIds)
|
|
246
|
+
? payloadSpecIds.filter((id) => typeof id === 'string')
|
|
247
|
+
: [];
|
|
248
|
+
const attribution = ctx.specId ?? (attributedSpecIds.length > 0 ? attributedSpecIds.join(', ') : undefined);
|
|
231
249
|
const lesson = await addLesson(ctx.projectPath, {
|
|
232
250
|
projectId: ctx.projectId,
|
|
233
251
|
specId: ctx.specId,
|
|
234
252
|
category: 'process',
|
|
235
253
|
severity: 'low',
|
|
236
254
|
title: 'Drift detected',
|
|
237
|
-
description: `Spec drift was detected${
|
|
255
|
+
description: `Spec drift was detected${attribution !== undefined ? ` for ${attribution}` : ''}.`,
|
|
238
256
|
prevention: 'Review spec file alignment with implementation after each commit.',
|
|
239
257
|
tags: ['drift', 'autopilot'],
|
|
240
258
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { checkGenericSpecOutput } from '../spec-quality/generic-output-gate.js';
|
|
1
|
+
import { checkGenericSpecOutput, STRUCTURAL_INTERPOLATION_KIND, } from '../spec-quality/generic-output-gate.js';
|
|
2
2
|
import { checkGroundedSpecContract } from '../spec-grounding/contract.js';
|
|
3
3
|
export const DEFAULT_SDD_CONSTITUTION_RULES = [
|
|
4
4
|
{
|
|
@@ -81,7 +81,11 @@ export function evaluateSddConstitutionRules(args) {
|
|
|
81
81
|
}
|
|
82
82
|
if (rules.some((rule) => rule.id === 'sdd.no-generic-output')) {
|
|
83
83
|
const generic = checkGenericSpecOutput(args.content);
|
|
84
|
-
violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules
|
|
84
|
+
violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules,
|
|
85
|
+
// SPEC-1406: structural-interpolation findings are advisory here — the blocking
|
|
86
|
+
// enforcement point for this new detector is create_spec, not the constitution
|
|
87
|
+
// gate, so ~1100 already-persisted specs stay transitionable.
|
|
88
|
+
issue.kind === STRUCTURAL_INTERPOLATION_KIND ? 'advisory' : undefined)));
|
|
85
89
|
}
|
|
86
90
|
return violations;
|
|
87
91
|
}
|
|
@@ -92,11 +96,11 @@ export function renderSddConstitutionRulesForHost() {
|
|
|
92
96
|
...enabled.map((rule) => `- ${rule.id} (${rule.level}): ${rule.description} Next action: ${rule.nextAction}`),
|
|
93
97
|
].join('\n');
|
|
94
98
|
}
|
|
95
|
-
function violation(ruleId, message, evidence, rules) {
|
|
99
|
+
function violation(ruleId, message, evidence, rules, levelOverride) {
|
|
96
100
|
const rule = rules.find((candidate) => candidate.id === ruleId);
|
|
97
101
|
return {
|
|
98
102
|
ruleId,
|
|
99
|
-
level: rule?.level ?? 'blocking',
|
|
103
|
+
level: levelOverride ?? rule?.level ?? 'blocking',
|
|
100
104
|
message,
|
|
101
105
|
evidence,
|
|
102
106
|
nextAction: rule?.nextAction ?? 'Fix the violated SDD constitution rule.',
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// engine/drift/violation-resolver.ts — Semi-automatic drift violation resolution (SPEC-408)
|
|
2
|
-
import { mkdir,
|
|
2
|
+
import { mkdir, access } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { specStore } from '../../storage/index.js';
|
|
5
5
|
import { DriftCacheStore } from '../../storage/drift-cache-store.js';
|
|
6
|
+
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Helpers
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
@@ -16,10 +17,22 @@ async function specDirExists(specDir) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
async function createFollowUpSpec(projectPath, parentSpecId, reason, dryRun) {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// SPEC-1405 (AC2): the id is deterministic per parent spec, not timestamp-keyed.
|
|
21
|
+
// A timestamped id made every drift pass create a brand-new follow-up directory,
|
|
22
|
+
// so repeated triggers spammed duplicates instead of converging on one record.
|
|
23
|
+
const specId = `SPEC-followup-${parentSpecId.toLowerCase()}`;
|
|
21
24
|
if (!dryRun) {
|
|
22
25
|
const specDir = join(projectPath, 'planu', 'specs', specId);
|
|
26
|
+
const specPath = join(specDir, 'spec.md');
|
|
27
|
+
// Already created by an earlier pass — leave it alone. Rewriting would clobber
|
|
28
|
+
// any edits made to the follow-up spec since it was raised.
|
|
29
|
+
try {
|
|
30
|
+
await access(specPath);
|
|
31
|
+
return specId;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Not present yet — fall through and create it.
|
|
35
|
+
}
|
|
23
36
|
await mkdir(specDir, { recursive: true });
|
|
24
37
|
const now = new Date().toISOString();
|
|
25
38
|
const specContent = [
|
|
@@ -42,7 +55,7 @@ async function createFollowUpSpec(projectPath, parentSpecId, reason, dryRun) {
|
|
|
42
55
|
'- [ ] Run detect_drift to confirm score returns to 0',
|
|
43
56
|
'',
|
|
44
57
|
].join('\n');
|
|
45
|
-
await
|
|
58
|
+
await atomicWriteFile(specPath, specContent);
|
|
46
59
|
}
|
|
47
60
|
return specId;
|
|
48
61
|
}
|
|
@@ -88,12 +101,21 @@ export async function resolveDriftViolations(projectPath, projectId, dryRun = fa
|
|
|
88
101
|
continue;
|
|
89
102
|
}
|
|
90
103
|
if (spec.status === 'done') {
|
|
91
|
-
// Code diverged from a completed spec — create follow-up
|
|
104
|
+
// Code diverged from a completed spec — create follow-up.
|
|
105
|
+
// The write is atomic (tmp + fsync + rename); if it throws (e.g. the
|
|
106
|
+
// target spec.md is frozen), count the violation as unresolved instead
|
|
107
|
+
// of letting the error escape into the fire-and-forget autopilot handler.
|
|
92
108
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
93
109
|
const reason = `Drift detected (score: ${entry.driftScore.toFixed(2)}) in files: ${(entry.driftedFiles ?? []).join(', ') || 'unknown'}`;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
try {
|
|
111
|
+
const followUpId = await createFollowUpSpec(projectPath, spec.id, reason, dryRun);
|
|
112
|
+
followUpSpecsCreated.push(followUpId);
|
|
113
|
+
resolved.push(buildDoneButDivergedResolution(spec.id));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
117
|
+
manualReview.push(`${spec.id}: follow-up spec write failed — ${msg}`);
|
|
118
|
+
}
|
|
97
119
|
}
|
|
98
120
|
else if (spec.status === 'implementing') {
|
|
99
121
|
// In-progress spec with code ahead — annotate but don't block
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
// evidence-skeletons.ts — SPEC-1356: auto-generate schema-valid
|
|
2
|
-
//
|
|
3
|
-
// of blocking on a manual authoring step.
|
|
4
|
-
//
|
|
1
|
+
// evidence-skeletons.ts — SPEC-1356: auto-generate a schema-valid task-plan skeleton when the
|
|
2
|
+
// underlying handoff evidence file is missing, so the `implementing` lifecycle gate self-heals
|
|
3
|
+
// instead of blocking on a manual authoring step.
|
|
4
|
+
//
|
|
5
|
+
// SPEC-1406 (DEFECT 3): `generateDiscoverySkeleton` is kept here (still exercised directly by
|
|
6
|
+
// its own tests) but is no longer wired into the `approved` gate's autofill path — writing a
|
|
7
|
+
// fabricated discovery skeleton made the blocking discovery-evidence gate self-certify. The
|
|
8
|
+
// `approved` transition now reports `discovery_missing` with the specific missing fields
|
|
9
|
+
// instead of silently manufacturing the evidence it was supposed to require.
|
|
5
10
|
import { join } from 'node:path';
|
|
6
11
|
import { extractListItems, extractSection } from '../spec-format/markdown-sections.js';
|
|
7
12
|
import { parseCriterionGroundingRecords } from '../spec-grounding/contract.js';
|
|
@@ -84,6 +84,25 @@ function validationPassed(result) {
|
|
|
84
84
|
const report = structured?.validationReport;
|
|
85
85
|
return structured?.ready === true && report?.passed === true;
|
|
86
86
|
}
|
|
87
|
+
function withEnrichedValidationReportSha(structuredContent, sha) {
|
|
88
|
+
const validationReport = structuredContent.validationReport;
|
|
89
|
+
const artifactRefs = structuredContent.artifactRefs;
|
|
90
|
+
return {
|
|
91
|
+
...structuredContent,
|
|
92
|
+
...(validationReport ? { validationReport: { ...validationReport, sha } } : {}),
|
|
93
|
+
...(artifactRefs ? { artifactRefs: { ...artifactRefs, validationReportSha: sha } } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function buildReceiptResult(checkpointResult, receiptId, receiptKey, enrichedReportSha) {
|
|
97
|
+
const baseStructuredContent = checkpointResult.structuredContent ?? {};
|
|
98
|
+
const structuredContent = enrichedReportSha
|
|
99
|
+
? withEnrichedValidationReportSha(baseStructuredContent, enrichedReportSha)
|
|
100
|
+
: baseStructuredContent;
|
|
101
|
+
return {
|
|
102
|
+
...checkpointResult,
|
|
103
|
+
structuredContent: { ...structuredContent, receiptId, receiptKey },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
87
106
|
async function closeFreshnessLeaseSafely(lease) {
|
|
88
107
|
if (!lease) {
|
|
89
108
|
return;
|
|
@@ -690,20 +709,14 @@ export class ValidateJobExecutor {
|
|
|
690
709
|
throw new Error('[Planu] Validation inputs changed during receipt publication');
|
|
691
710
|
}
|
|
692
711
|
const receiptKey = `validation-receipt:${receipt.receiptId.slice('sha256:'.length)}`;
|
|
712
|
+
let enrichedReportSha;
|
|
693
713
|
try {
|
|
694
|
-
await this.persistReceiptArtifacts(identity, payload, receipt.receiptId, receiptKey);
|
|
714
|
+
enrichedReportSha = await this.persistReceiptArtifacts(identity, payload, receipt.receiptId, receiptKey);
|
|
695
715
|
}
|
|
696
716
|
catch (error) {
|
|
697
717
|
throw new ReceiptPublicationInterruptedError({ cause: error });
|
|
698
718
|
}
|
|
699
|
-
return
|
|
700
|
-
...checkpoint.result,
|
|
701
|
-
structuredContent: {
|
|
702
|
-
...(checkpoint.result.structuredContent ?? {}),
|
|
703
|
-
receiptId: receipt.receiptId,
|
|
704
|
-
receiptKey,
|
|
705
|
-
},
|
|
706
|
-
};
|
|
719
|
+
return buildReceiptResult(checkpoint.result, receipt.receiptId, receiptKey, enrichedReportSha);
|
|
707
720
|
}
|
|
708
721
|
finally {
|
|
709
722
|
database.close();
|
|
@@ -759,13 +772,15 @@ export class ValidateJobExecutor {
|
|
|
759
772
|
kind: 'validation-report',
|
|
760
773
|
});
|
|
761
774
|
if (existingReport.ok) {
|
|
762
|
-
await appendArtifact({
|
|
775
|
+
const rewritten = await appendArtifact({
|
|
763
776
|
projectId: identity.projectId,
|
|
764
777
|
specId: payload.bindings.specId,
|
|
765
778
|
kind: 'validation-report',
|
|
766
779
|
payload: { ...existingReport.payload, receiptId, receiptKey },
|
|
767
780
|
});
|
|
781
|
+
return `sha256:${rewritten.sha}`;
|
|
768
782
|
}
|
|
783
|
+
return undefined;
|
|
769
784
|
}
|
|
770
785
|
}
|
|
771
786
|
/** Schedules background execution after the submit response can be flushed. */
|
|
@@ -444,12 +444,19 @@ function extractBacktickedFiles(content) {
|
|
|
444
444
|
}
|
|
445
445
|
return [...files].sort();
|
|
446
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* SPEC-1406 (DEFECT 6): markdown heading lines (e.g. "### Edge Cases And Failure Modes")
|
|
449
|
+
* match keyword regexes like /\bedge\b/ just as readily as real bullet content, so headings
|
|
450
|
+
* used to leak into testPlan/risks/ownership as if they were grounded items. Skip headings
|
|
451
|
+
* and dedupe before truncating, so a criterion repeated under multiple sections — or the same
|
|
452
|
+
* heading matching two keyword sets — is not counted as distinct evidence twice.
|
|
453
|
+
*/
|
|
447
454
|
function extractLinesByKeywords(content, keywords) {
|
|
448
|
-
|
|
455
|
+
const lines = content
|
|
449
456
|
.split('\n')
|
|
450
457
|
.map((line) => line.trim().replace(/^[-*]\s*/, ''))
|
|
451
|
-
.filter((line) => line.length > 0 && keywords.test(line))
|
|
452
|
-
|
|
458
|
+
.filter((line) => line.length > 0 && !/^#{1,6}\s/.test(line) && keywords.test(line));
|
|
459
|
+
return [...new Set(lines)].slice(0, 12);
|
|
453
460
|
}
|
|
454
461
|
function extractOperationalSections(content, spec) {
|
|
455
462
|
return {
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
export declare const IMPLEMENTATION_CONTRACT_SECTION = "Implementation Contract";
|
|
2
|
-
|
|
2
|
+
/** Subsections every rendered contract must include — omitting one is a defect. */
|
|
3
|
+
export declare const IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS: readonly ['User Outcome', 'File-Level Work Plan', 'Acceptance-To-Verification Map', 'Verification Commands'];
|
|
4
|
+
/**
|
|
5
|
+
* SPEC-1406: subsections that carry no grounded content are omitted from the rendered
|
|
6
|
+
* body rather than filled with restated-criterion filler. Their absence is not an
|
|
7
|
+
* evaluator defect — only their presence-but-empty is. This keeps the ~1100
|
|
8
|
+
* already-persisted specs that still carry these sections (with real, non-empty
|
|
9
|
+
* content) transitionable: `evaluateImplementationContract` only requires them when
|
|
10
|
+
* the rendered body chose to include the heading.
|
|
11
|
+
*/
|
|
12
|
+
export declare const IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS: readonly ['Behavior Contract', 'Edge Cases And Failure Modes', 'Non-Goals And Forbidden Approaches'];
|
|
13
|
+
/** Union of required + optional subsections. Kept for callers that only need the full set. */
|
|
14
|
+
export declare const IMPLEMENTATION_CONTRACT_SUBSECTIONS: readonly ["User Outcome", "File-Level Work Plan", "Acceptance-To-Verification Map", "Verification Commands", "Behavior Contract", "Edge Cases And Failure Modes", "Non-Goals And Forbidden Approaches"];
|
|
3
15
|
export declare function hasImplementationContract(specBody: string): boolean;
|
|
4
16
|
export declare function extractTopLevelSection(body: string, sectionName: string): string;
|
|
5
17
|
//# sourceMappingURL=common.d.ts.map
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
export const IMPLEMENTATION_CONTRACT_SECTION = 'Implementation Contract';
|
|
2
|
-
|
|
2
|
+
/** Subsections every rendered contract must include — omitting one is a defect. */
|
|
3
|
+
export const IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS = [
|
|
3
4
|
'User Outcome',
|
|
4
|
-
'Behavior Contract',
|
|
5
5
|
'File-Level Work Plan',
|
|
6
6
|
'Acceptance-To-Verification Map',
|
|
7
|
+
'Verification Commands',
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* SPEC-1406: subsections that carry no grounded content are omitted from the rendered
|
|
11
|
+
* body rather than filled with restated-criterion filler. Their absence is not an
|
|
12
|
+
* evaluator defect — only their presence-but-empty is. This keeps the ~1100
|
|
13
|
+
* already-persisted specs that still carry these sections (with real, non-empty
|
|
14
|
+
* content) transitionable: `evaluateImplementationContract` only requires them when
|
|
15
|
+
* the rendered body chose to include the heading.
|
|
16
|
+
*/
|
|
17
|
+
export const IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS = [
|
|
18
|
+
'Behavior Contract',
|
|
7
19
|
'Edge Cases And Failure Modes',
|
|
8
20
|
'Non-Goals And Forbidden Approaches',
|
|
9
|
-
|
|
21
|
+
];
|
|
22
|
+
/** Union of required + optional subsections. Kept for callers that only need the full set. */
|
|
23
|
+
export const IMPLEMENTATION_CONTRACT_SUBSECTIONS = [
|
|
24
|
+
...IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS,
|
|
25
|
+
...IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS,
|
|
10
26
|
];
|
|
11
27
|
export function hasImplementationContract(specBody) {
|
|
12
28
|
return extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION).trim().length > 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractTopLevelSection,
|
|
1
|
+
import { extractTopLevelSection, IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS, IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS, IMPLEMENTATION_CONTRACT_SECTION, } from './common.js';
|
|
2
2
|
const FILLER_PATTERNS = [
|
|
3
3
|
/\bTBD\b/i,
|
|
4
4
|
/\bN\/A\b/i,
|
|
@@ -8,6 +8,19 @@ const FILLER_PATTERNS = [
|
|
|
8
8
|
];
|
|
9
9
|
const MANUAL_VERIFICATION_STEP_RE = /\b(given|when|then|step|observe|click|run)\b/i;
|
|
10
10
|
const CONCRETE_MANUAL_EVIDENCE_RE = /`[^`]+`|\b(?:src|tests|docs|scripts|website|planu)\/[\w./-]+/i;
|
|
11
|
+
const OPTIONAL_SUBSECTIONS = new Set(IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS);
|
|
12
|
+
/**
|
|
13
|
+
* SPEC-1406 (DEFECT 1, round 2 — FALSE EVIDENCE fix): the old CONCRETE_MANUAL_EVIDENCE_RE
|
|
14
|
+
* accepted ANY backticked text, so a row whose evidence was `` `not-a-path` `` produced zero
|
|
15
|
+
* issues. Verification-row evidence must actually look like a test path (a `tests/` segment,
|
|
16
|
+
* or `.test.`/`.spec.` in the filename), a runnable test/typecheck/lint command, or a
|
|
17
|
+
* backticked command/path that names a real file or script (contains `/` or a file
|
|
18
|
+
* extension) — a bare backticked word with neither is not evidence.
|
|
19
|
+
*/
|
|
20
|
+
const TEST_EVIDENCE_RE = /tests?\/[\w./-]+\.\w+|\.(?:test|spec)\.\w+|\b(?:pnpm|npm|npx|yarn)\s+(?:run\s+)?[\w:-]*(?:test|typecheck|lint)[\w:-]*\b|\bvitest\b|\bjest\b|\bpytest\b|\bgo test\b|\bcargo test\b|`[^`]*(?:[\\/]|\.[a-zA-Z]{1,10}\b)[^`]*`/i;
|
|
21
|
+
/** The shared row rendered by renderSharedVerificationRow when there are fewer test files
|
|
22
|
+
* than criteria — it covers every criterion at once rather than one row each. */
|
|
23
|
+
const SHARED_VERIFICATION_ROW_RE = /^-\s*All criteria are verified by:/;
|
|
11
24
|
export function evaluateImplementationContract(specBody, acceptanceCriteria) {
|
|
12
25
|
const contract = extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION);
|
|
13
26
|
if (contract.trim().length === 0) {
|
|
@@ -19,56 +32,77 @@ export function evaluateImplementationContract(specBody, acceptanceCriteria) {
|
|
|
19
32
|
];
|
|
20
33
|
}
|
|
21
34
|
const issues = [];
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
]
|
|
35
|
+
const allSubsections = [
|
|
36
|
+
...IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS,
|
|
37
|
+
...IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS,
|
|
38
|
+
];
|
|
39
|
+
const sections = new Map(allSubsections.map((section) => [section, extractSubsection(contract, section)]));
|
|
26
40
|
for (const [section, content] of sections.entries()) {
|
|
27
|
-
|
|
41
|
+
issues.push(...evaluateSubsection(section, content));
|
|
42
|
+
}
|
|
43
|
+
const map = sections.get('Acceptance-To-Verification Map') ?? '';
|
|
44
|
+
const mapLines = map.split('\n').map((line) => line.trim());
|
|
45
|
+
const sharedRowCoversAll = mapLines.some((line) => SHARED_VERIFICATION_ROW_RE.test(line) && TEST_EVIDENCE_RE.test(line));
|
|
46
|
+
const useStrictIndex = hasCompleteAcLabeling(mapLines, acceptanceCriteria.length);
|
|
47
|
+
acceptanceCriteria.forEach((criterion, index) => {
|
|
48
|
+
if (sharedRowCoversAll) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!criterionHasVerification(criterion, index + 1, mapLines, useStrictIndex)) {
|
|
52
|
+
issues.push({
|
|
53
|
+
code: 'contract_unmapped_criterion',
|
|
54
|
+
message: `Acceptance criterion is not mapped to verification evidence: ${criterion}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
if (/\bmanual verification\b/i.test(map) &&
|
|
59
|
+
!MANUAL_VERIFICATION_STEP_RE.test(map) &&
|
|
60
|
+
!CONCRETE_MANUAL_EVIDENCE_RE.test(map)) {
|
|
61
|
+
issues.push({
|
|
62
|
+
code: 'contract_manual_verification_vague',
|
|
63
|
+
message: 'Manual verification in the Implementation Contract must include exact observable steps.',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return issues;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* SPEC-1406: optional subsections with no grounded content are omitted from the rendered
|
|
70
|
+
* body entirely — that is the intended fix, not a defect to flag.
|
|
71
|
+
*/
|
|
72
|
+
function evaluateSubsection(section, content) {
|
|
73
|
+
const issues = [];
|
|
74
|
+
if (content === null) {
|
|
75
|
+
if (!OPTIONAL_SUBSECTIONS.has(section)) {
|
|
28
76
|
issues.push({
|
|
29
77
|
code: 'contract_subsection_missing',
|
|
30
78
|
message: `Implementation Contract subsection "${section}" is missing.`,
|
|
31
79
|
});
|
|
32
|
-
continue;
|
|
33
80
|
}
|
|
34
|
-
|
|
35
|
-
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
const trimmed = content.trim();
|
|
84
|
+
if (trimmed.length === 0) {
|
|
85
|
+
if (!OPTIONAL_SUBSECTIONS.has(section)) {
|
|
36
86
|
issues.push({
|
|
37
87
|
code: 'contract_subsection_empty',
|
|
38
88
|
message: `Implementation Contract subsection "${section}" is empty.`,
|
|
39
89
|
});
|
|
40
90
|
}
|
|
41
|
-
|
|
42
|
-
if (pattern.test(trimmed)) {
|
|
43
|
-
issues.push({
|
|
44
|
-
code: 'contract_filler',
|
|
45
|
-
message: `Implementation Contract subsection "${section}" contains filler text.`,
|
|
46
|
-
});
|
|
47
|
-
break;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (/\bNeeds decision:/i.test(trimmed)) {
|
|
51
|
-
issues.push({
|
|
52
|
-
code: 'contract_needs_decision',
|
|
53
|
-
message: `Implementation Contract subsection "${section}" still has unresolved Needs decision items.`,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
91
|
+
return issues;
|
|
56
92
|
}
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
if (!criterionHasVerification(criterion, map)) {
|
|
93
|
+
for (const pattern of FILLER_PATTERNS) {
|
|
94
|
+
if (pattern.test(trimmed)) {
|
|
60
95
|
issues.push({
|
|
61
|
-
code: '
|
|
62
|
-
message: `
|
|
96
|
+
code: 'contract_filler',
|
|
97
|
+
message: `Implementation Contract subsection "${section}" contains filler text.`,
|
|
63
98
|
});
|
|
99
|
+
break;
|
|
64
100
|
}
|
|
65
101
|
}
|
|
66
|
-
if (/\
|
|
67
|
-
!MANUAL_VERIFICATION_STEP_RE.test(map) &&
|
|
68
|
-
!CONCRETE_MANUAL_EVIDENCE_RE.test(map)) {
|
|
102
|
+
if (/\bNeeds decision:/i.test(trimmed)) {
|
|
69
103
|
issues.push({
|
|
70
|
-
code: '
|
|
71
|
-
message:
|
|
104
|
+
code: 'contract_needs_decision',
|
|
105
|
+
message: `Implementation Contract subsection "${section}" still has unresolved Needs decision items.`,
|
|
72
106
|
});
|
|
73
107
|
}
|
|
74
108
|
return issues;
|
|
@@ -85,7 +119,54 @@ function extractSubsection(sectionBody, subsectionName) {
|
|
|
85
119
|
const next = nextRe.exec(sectionBody);
|
|
86
120
|
return sectionBody.slice(start, next ? next.index : sectionBody.length);
|
|
87
121
|
}
|
|
88
|
-
|
|
122
|
+
/**
|
|
123
|
+
* A map only qualifies for strict index-based matching when EVERY criterion 1..N has its
|
|
124
|
+
* own `AC{n}:` row — a partial or off-by-one labeling scheme (e.g. an extra `AB1:` row for
|
|
125
|
+
* a review-only criterion) is not the renderer's per-criterion output shape and must not
|
|
126
|
+
* be forced into it; fall back to the legacy whole-map match instead (see below).
|
|
127
|
+
*/
|
|
128
|
+
function hasCompleteAcLabeling(mapLines, criteriaCount) {
|
|
129
|
+
if (criteriaCount === 0) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
for (let index = 1; index <= criteriaCount; index += 1) {
|
|
133
|
+
const rowRe = new RegExp(`^-\\s*AC${String(index)}:`);
|
|
134
|
+
if (!mapLines.some((line) => rowRe.test(line))) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* SPEC-1406 (DEFECT 1, round 2 — PREFIX COLLISION fix): the old lookup matched rows by
|
|
142
|
+
* `normalize(criterion).slice(0, 48)` and took the FIRST matching line — two criteria
|
|
143
|
+
* sharing the same first 48 normalized characters were both "certified" by AC1's row,
|
|
144
|
+
* so AC2 could be entirely unmapped and still pass. `AC{n}:` labels are only ever emitted
|
|
145
|
+
* by this renderer (renderPerCriterionRow), so when the map has a complete `AC1:`..`ACn:`
|
|
146
|
+
* sequence, match strictly on the labeled index instead of a truncated prefix.
|
|
147
|
+
*
|
|
148
|
+
* When the map does not have a complete AC-labeled sequence, it was authored by hand or by
|
|
149
|
+
* an older generator (bullet lists, GIVEN/WHEN/THEN prose, markdown tables, partial AC
|
|
150
|
+
* labeling — corpus-replay against all pre-SPEC-1406 persisted specs found no single
|
|
151
|
+
* positional or structural rule that covers every legacy shape without newly blocking specs
|
|
152
|
+
* that were previously readable). Falling back to the original whole-map prefix/keyword
|
|
153
|
+
* match preserves exact pre-fix behavior for that legacy corpus — the non-functional
|
|
154
|
+
* constraint that no already-persisted spec may gain a new blocker takes priority over
|
|
155
|
+
* tightening a shape this fix does not target.
|
|
156
|
+
*/
|
|
157
|
+
function criterionHasVerification(criterion, index, mapLines, useStrictIndex) {
|
|
158
|
+
if (criterion.trim().length === 0) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (useStrictIndex) {
|
|
162
|
+
const rowRe = new RegExp(`^-\\s*AC${String(index)}:`);
|
|
163
|
+
const row = mapLines.find((line) => rowRe.test(line));
|
|
164
|
+
return row !== undefined && TEST_EVIDENCE_RE.test(row);
|
|
165
|
+
}
|
|
166
|
+
return legacyCriterionHasVerification(criterion, mapLines.join('\n'));
|
|
167
|
+
}
|
|
168
|
+
/** Pre-SPEC-1406 whole-map matcher, kept verbatim for maps with no `AC{n}:` labels. */
|
|
169
|
+
function legacyCriterionHasVerification(criterion, map) {
|
|
89
170
|
const normalizedCriterion = normalize(criterion);
|
|
90
171
|
if (normalizedCriterion.length === 0) {
|
|
91
172
|
return true;
|