@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
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.3.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.5",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.5",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "266c514673274fe29f5d625cf33524e203371741f320e6933c0231824506e433"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.3.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.5",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,16 +1,4 @@
|
|
|
1
1
|
import { type ReconcileChange, type ApplyChangesResult } from '../../types/index.js';
|
|
2
2
|
export type { ApplyChangesResult, SkippedChange } from '../../types/index.js';
|
|
3
|
-
/**
|
|
4
|
-
* Apply approved text changes to the spec.md body.
|
|
5
|
-
*
|
|
6
|
-
* For each non-metadata approved change:
|
|
7
|
-
* 1. Locate the named section's body via heading anchor.
|
|
8
|
-
* 2. Require exactly one occurrence of `originalValue` inside that section.
|
|
9
|
-
* 3. Replace it in-place at the resolved absolute offset.
|
|
10
|
-
*
|
|
11
|
-
* Skips (with reason) when the section heading is absent, the value is missing
|
|
12
|
-
* inside the section, or the value appears multiple times (ambiguous match).
|
|
13
|
-
* Writes only when at least one substitution succeeded.
|
|
14
|
-
*/
|
|
15
3
|
export declare function applyChangesToSpec(specPath: string, changes: ReconcileChange[]): Promise<ApplyChangesResult>;
|
|
16
4
|
//# sourceMappingURL=apply-changes.d.ts.map
|
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
// engine/reconcile/apply-changes.ts — SPEC-1011 Bug D: Apply approved text changes to spec.md
|
|
2
|
-
// Replacement is section-anchored to prevent silent cross-section mutations:
|
|
3
|
-
// the originalValue is searched only inside the named section's body (between its
|
|
4
|
-
// heading and the next heading at the same or shallower level), and the change is
|
|
5
|
-
// applied only when the match is unique within those bounds.
|
|
6
1
|
import { readFile } from 'node:fs/promises';
|
|
7
2
|
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
8
|
-
import {
|
|
3
|
+
import { SECTIONS_WITHOUT_LITERAL_BODY_TEXT, } from '../../types/index.js';
|
|
9
4
|
function escapeRegex(str) {
|
|
10
5
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
6
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Find the body bounds of a markdown section by heading name.
|
|
14
|
-
* Returns the [start, end) offsets of the section body (after the heading line,
|
|
15
|
-
* up to the next heading at the same or shallower level — or EOF).
|
|
16
|
-
*/
|
|
17
7
|
function findSectionBounds(content, section) {
|
|
18
8
|
const headingRe = new RegExp(`^(#{1,6})\\s+${escapeRegex(section)}\\s*$`, 'mi');
|
|
19
9
|
const match = headingRe.exec(content);
|
|
@@ -22,16 +12,12 @@ function findSectionBounds(content, section) {
|
|
|
22
12
|
}
|
|
23
13
|
const headingLevel = match[1]?.length ?? 0;
|
|
24
14
|
const start = match.index + match[0].length;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const next = nextRe.exec(content);
|
|
15
|
+
const nextHeadingAtSameOrShallowerLevelRe = new RegExp(`^#{1,${String(headingLevel)}}\\s+\\S`, 'gm');
|
|
16
|
+
nextHeadingAtSameOrShallowerLevelRe.lastIndex = start;
|
|
17
|
+
const next = nextHeadingAtSameOrShallowerLevelRe.exec(content);
|
|
29
18
|
return { start, end: next ? next.index : content.length };
|
|
30
19
|
}
|
|
31
|
-
|
|
32
|
-
* Count non-overlapping occurrences of `needle` in `haystack`.
|
|
33
|
-
*/
|
|
34
|
-
function countOccurrences(haystack, needle) {
|
|
20
|
+
function countNonOverlappingOccurrences(haystack, needle) {
|
|
35
21
|
if (needle.length === 0) {
|
|
36
22
|
return 0;
|
|
37
23
|
}
|
|
@@ -45,20 +31,8 @@ function countOccurrences(haystack, needle) {
|
|
|
45
31
|
}
|
|
46
32
|
return count;
|
|
47
33
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Apply approved text changes to the spec.md body.
|
|
50
|
-
*
|
|
51
|
-
* For each non-metadata approved change:
|
|
52
|
-
* 1. Locate the named section's body via heading anchor.
|
|
53
|
-
* 2. Require exactly one occurrence of `originalValue` inside that section.
|
|
54
|
-
* 3. Replace it in-place at the resolved absolute offset.
|
|
55
|
-
*
|
|
56
|
-
* Skips (with reason) when the section heading is absent, the value is missing
|
|
57
|
-
* inside the section, or the value appears multiple times (ambiguous match).
|
|
58
|
-
* Writes only when at least one substitution succeeded.
|
|
59
|
-
*/
|
|
60
34
|
export async function applyChangesToSpec(specPath, changes) {
|
|
61
|
-
const approvedTextChanges = changes.filter((c) => c.approved && !
|
|
35
|
+
const approvedTextChanges = changes.filter((c) => c.approved && !SECTIONS_WITHOUT_LITERAL_BODY_TEXT.has(c.section));
|
|
62
36
|
if (approvedTextChanges.length === 0) {
|
|
63
37
|
return { applied: 0, skipped: [], fileWritten: false };
|
|
64
38
|
}
|
|
@@ -74,6 +48,7 @@ export async function applyChangesToSpec(specPath, changes) {
|
|
|
74
48
|
section: c.section,
|
|
75
49
|
originalValue: c.originalValue,
|
|
76
50
|
reason: `Cannot read spec.md: ${reason}`,
|
|
51
|
+
reasonCategory: 'skipped',
|
|
77
52
|
})),
|
|
78
53
|
fileWritten: false,
|
|
79
54
|
};
|
|
@@ -88,16 +63,18 @@ export async function applyChangesToSpec(specPath, changes) {
|
|
|
88
63
|
section: change.section,
|
|
89
64
|
originalValue: change.originalValue,
|
|
90
65
|
reason: `Section heading "${change.section}" not found in spec.md`,
|
|
66
|
+
reasonCategory: 'skipped',
|
|
91
67
|
});
|
|
92
68
|
continue;
|
|
93
69
|
}
|
|
94
70
|
const sectionBody = modified.slice(bounds.start, bounds.end);
|
|
95
|
-
const occurrences =
|
|
71
|
+
const occurrences = countNonOverlappingOccurrences(sectionBody, change.originalValue);
|
|
96
72
|
if (occurrences === 0) {
|
|
97
73
|
skipped.push({
|
|
98
74
|
section: change.section,
|
|
99
75
|
originalValue: change.originalValue,
|
|
100
76
|
reason: `originalValue not found in section "${change.section}"`,
|
|
77
|
+
reasonCategory: 'skipped',
|
|
101
78
|
});
|
|
102
79
|
continue;
|
|
103
80
|
}
|
|
@@ -106,6 +83,7 @@ export async function applyChangesToSpec(specPath, changes) {
|
|
|
106
83
|
section: change.section,
|
|
107
84
|
originalValue: change.originalValue,
|
|
108
85
|
reason: `originalValue is ambiguous (${String(occurrences)} matches in section "${change.section}")`,
|
|
86
|
+
reasonCategory: 'ambiguous',
|
|
109
87
|
});
|
|
110
88
|
continue;
|
|
111
89
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// engine/reconcile/verify-write.ts — SPEC-1011 Bug D: Post-write integrity verification
|
|
2
2
|
// After reconcile_spec writes changes, re-reads the file to assert each newValue is present.
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import {
|
|
4
|
+
import { SECTIONS_WITHOUT_LITERAL_BODY_TEXT, } from '../../types/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Re-reads the file at `filePath` and checks that each approved change's `newValue`
|
|
7
7
|
* is present somewhere in the file content.
|
|
@@ -30,8 +30,7 @@ export async function verifyWriteSucceeded(filePath, changes) {
|
|
|
30
30
|
}
|
|
31
31
|
const missingChanges = [];
|
|
32
32
|
for (const change of approvedChanges) {
|
|
33
|
-
|
|
34
|
-
if (RECONCILE_METADATA_SECTIONS.has(change.section)) {
|
|
33
|
+
if (SECTIONS_WITHOUT_LITERAL_BODY_TEXT.has(change.section)) {
|
|
35
34
|
continue;
|
|
36
35
|
}
|
|
37
36
|
if (!content.includes(change.newValue)) {
|
|
@@ -67,7 +67,23 @@ function buildCoverageMap(rulesFiles, allRules) {
|
|
|
67
67
|
*
|
|
68
68
|
* A file is "stale" when it has NO categories in common with CLAUDE.md categories.
|
|
69
69
|
*/
|
|
70
|
-
function
|
|
70
|
+
function manifestOwnedRuleFiles(projectPath) {
|
|
71
|
+
const raw = readFileSafe(join(projectPath, '.claude', 'rules', '.planu-rules-manifest.json'));
|
|
72
|
+
if (!raw) {
|
|
73
|
+
return new Set();
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const manifest = JSON.parse(raw);
|
|
77
|
+
return new Set((manifest.rules ?? [])
|
|
78
|
+
.map((rule) => rule.path)
|
|
79
|
+
.filter((path) => typeof path === 'string')
|
|
80
|
+
.map((path) => basename(path)));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return new Set();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function detectStaleRules(rulesFiles, allRules, claudeCategories, manifestOwned) {
|
|
71
87
|
// Build per-file category sets
|
|
72
88
|
const fileCategories = new Map();
|
|
73
89
|
for (const r of allRules) {
|
|
@@ -80,6 +96,9 @@ function detectStaleRules(rulesFiles, allRules, claudeCategories) {
|
|
|
80
96
|
}
|
|
81
97
|
const stale = [];
|
|
82
98
|
for (const filePath of rulesFiles) {
|
|
99
|
+
if (manifestOwned.has(basename(filePath))) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
83
102
|
const cats = fileCategories.get(filePath);
|
|
84
103
|
if (!cats || cats.size === 0) {
|
|
85
104
|
// No parseable conventions found — candidate for stale
|
|
@@ -162,7 +181,7 @@ export function reconcileRules(projectPath) {
|
|
|
162
181
|
}
|
|
163
182
|
}
|
|
164
183
|
// 5. Detect stale rules files
|
|
165
|
-
const staleRules = detectStaleRules(rulesFiles, allRules, claudeCategories);
|
|
184
|
+
const staleRules = detectStaleRules(rulesFiles, allRules, claudeCategories, manifestOwnedRuleFiles(projectPath));
|
|
166
185
|
// 6. Auto-patch: generate new rules files for uncovered categories
|
|
167
186
|
const updatedRules = [];
|
|
168
187
|
for (const category of newRulesNeeded) {
|
|
@@ -5,7 +5,7 @@ import { stripFencedBlocks } from './text-fences.js';
|
|
|
5
5
|
import { createCriterionIdentity, normalizeCriterionText } from '../criterion-identity.js';
|
|
6
6
|
const BDD_STEP = /^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i;
|
|
7
7
|
const BDD_START = /^(GIVEN|DADO)\b/i;
|
|
8
|
-
const TECHNICAL_MARKER = /^(FILES|FUNCTIONS|TEST|OWNER):/i;
|
|
8
|
+
const TECHNICAL_MARKER = /^(FILES|FUNCTIONS|TEST|OWNER|NOTE):/i;
|
|
9
9
|
const EXPLICIT_CRITERION_HEADING = /^#{3,6}\s+((?:AC|AB)\s*-?\s*\d+)\b/i;
|
|
10
10
|
function extractBddBlocks(section) {
|
|
11
11
|
const lines = section.split('\n').map((line) => line.trim());
|
|
@@ -38,6 +38,12 @@ function extractBddBlocks(section) {
|
|
|
38
38
|
current.push(withoutListMarker);
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
|
+
// Wrapped continuation of the step above: keep it, or the criterion identity
|
|
42
|
+
// truncates at the physical Markdown wrap boundary (SPEC-1253). A line that
|
|
43
|
+
// carried its own list marker is a sibling item, never a wrap.
|
|
44
|
+
if (current.length > 0 && withoutListMarker === line) {
|
|
45
|
+
current.push(withoutListMarker);
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
if (current.length > 0) {
|
|
43
49
|
bddBlocks.push(current.join(' '));
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Closes the SPEC-630 residue: every new spec is born unified, never written as a
|
|
3
3
|
// two-file spec.md + technical.md pair only to be merged on the next list_specs run.
|
|
4
4
|
import { stripFrontmatter } from '../frontmatter-parser.js';
|
|
5
|
+
import { extractAcceptanceCriteriaTexts } from './acceptance-criteria.js';
|
|
5
6
|
const REQUIRED_SECTIONS = [
|
|
6
7
|
'Problem',
|
|
7
8
|
'Goal',
|
|
@@ -254,18 +255,41 @@ function firstSentence(text) {
|
|
|
254
255
|
.find((part) => part.length > 0 && !part.startsWith('#'));
|
|
255
256
|
return sentence ?? null;
|
|
256
257
|
}
|
|
257
|
-
function
|
|
258
|
-
const explicit = /\bGIVEN\b[\s\S]*?(?=(?:\n##|\bFILES:|\bFUNCTIONS:|\bTEST:|$))/i
|
|
259
|
-
.exec(source)?.[0]
|
|
260
|
-
?.trim()
|
|
261
|
-
.replace(/[.\s]+$/, '.');
|
|
262
|
-
if (explicit) {
|
|
263
|
-
return `- ${explicit}`;
|
|
264
|
-
}
|
|
258
|
+
function fallbackCriteriaBullets(criteria) {
|
|
265
259
|
return criteria
|
|
266
260
|
.map((criterion) => `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${criterion.text}`)
|
|
267
261
|
.join('\n');
|
|
268
262
|
}
|
|
263
|
+
// Same BDD step vocabulary `acceptance-criteria.ts` recognizes (English + Spanish).
|
|
264
|
+
// A criterion already starting with one of these is a genuine BDD fragment
|
|
265
|
+
// (a full GIVEN/WHEN/THEN block, or an AND continuation of one) — leave it as-is.
|
|
266
|
+
const BDD_STEP_START = /^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i;
|
|
267
|
+
/**
|
|
268
|
+
* Render one real criterion as an executable bullet. Criteria already
|
|
269
|
+
* written in BDD form pass through untouched; plain-text criteria (e.g.
|
|
270
|
+
* checkbox items) are wrapped in the same GIVEN/WHEN/THEN scaffold
|
|
271
|
+
* `fallbackCriteriaBullets` uses, so every bullet stays BDD-executable
|
|
272
|
+
* without ever discarding a real criterion.
|
|
273
|
+
*/
|
|
274
|
+
function toExecutableBullet(text) {
|
|
275
|
+
const cleaned = text.trim().replace(/[.\s]+$/, '.');
|
|
276
|
+
if (BDD_STEP_START.test(cleaned)) {
|
|
277
|
+
return `- ${cleaned}`;
|
|
278
|
+
}
|
|
279
|
+
return `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${cleaned}`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Criteria resolve through the canonical parser in `acceptance-criteria.ts`
|
|
283
|
+
* so the renderer and `readiness-checker.ts` can never disagree about how
|
|
284
|
+
* many criteria a spec has.
|
|
285
|
+
*/
|
|
286
|
+
function extractExecutableCriteria(source, criteria) {
|
|
287
|
+
const parsed = extractAcceptanceCriteriaTexts(source);
|
|
288
|
+
if (parsed.length === 0) {
|
|
289
|
+
return fallbackCriteriaBullets(criteria);
|
|
290
|
+
}
|
|
291
|
+
return parsed.map(toExecutableBullet).join('\n');
|
|
292
|
+
}
|
|
269
293
|
function renderOwnedFiles(files) {
|
|
270
294
|
const sections = [];
|
|
271
295
|
for (const [title, entries] of [
|
|
@@ -22,9 +22,11 @@ export class FallbackGenerator {
|
|
|
22
22
|
method: 'deterministic',
|
|
23
23
|
generatedAt: new Date().toISOString(),
|
|
24
24
|
},
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// SPEC-1406 (DEFECT 4, round 2): this generator is the only one wired into
|
|
26
|
+
// production (create-spec.ts:1430), so this warning fired unconditionally on
|
|
27
|
+
// every spec ever created — a warning with 100% incidence and no gate consumer
|
|
28
|
+
// carries no signal. Only qualityWarnings from actual quality issues remain.
|
|
29
|
+
qualityWarnings: [],
|
|
28
30
|
fallbackReason: 'No external model generator is configured for create_spec.',
|
|
29
31
|
});
|
|
30
32
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
import type { GenericSpecOutputGateResult } from '../../types/index.js';
|
|
1
|
+
import type { GenericSpecOutputGateResult, GenericSpecOutputIssueKind } from '../../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* SPEC-1406 (DEFECT 2 fix): the constant kind reused to mark structural-interpolation
|
|
4
|
+
* findings so callers can tell them apart from the other generic-output issue kinds and
|
|
5
|
+
* decide, per call site, whether that finding blocks or only warns.
|
|
6
|
+
*/
|
|
7
|
+
export declare const STRUCTURAL_INTERPOLATION_KIND: GenericSpecOutputIssueKind;
|
|
2
8
|
export declare function checkGenericSpecOutput(content: string): GenericSpecOutputGateResult;
|
|
3
9
|
//# sourceMappingURL=generic-output-gate.d.ts.map
|
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
|
|
1
|
+
/** Bounds the search for criteria to the frontmatter block only (between the two `---`
|
|
2
|
+
* delimiters), so criterion-shaped lines never leak in from the rendered body below it. */
|
|
3
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
|
|
4
|
+
/**
|
|
5
|
+
* SPEC-1406 (DEFECT 1, round 4 — detector was inert on real specs): matches a criterion's
|
|
6
|
+
* `- text: "..."` line regardless of nesting depth, so it covers BOTH shapes this generator
|
|
7
|
+
* renders (`lean-spec-generator.ts`): the top-level checkbox `criteria:` list AND the BDD
|
|
8
|
+
* `grounding: criteria:` list nested under `grounding:` (see
|
|
9
|
+
* `contract.ts:renderGroundingFrontmatter`, ` - text: "..."`). The previous version only
|
|
10
|
+
* looked inside a slice bounded by a single-line lookahead that matched line-end (`\s*$`
|
|
11
|
+
* under `/m`) after the FIRST criterion, so multi-criterion checkbox specs (e.g. SPEC-001,
|
|
12
|
+
* 6 criteria) only ever contributed their first entry — and it never ran at all against
|
|
13
|
+
* `scenarios:`-shaped (BDD) specs, which have no top-level `criteria:` key, so criteria was
|
|
14
|
+
* always `[]` there and `isInterpolatedTemplate`'s `criteria.length === 0` guard made every
|
|
15
|
+
* BDD spec's filler unflaggable by construction. Scanning the whole frontmatter block with
|
|
16
|
+
* this regex (plus SCENARIO_TITLE_RE below) fixes both defects in one pass — no truncating
|
|
17
|
+
* section boundary is needed once matching isn't anchored to a single top-level key.
|
|
18
|
+
*/
|
|
2
19
|
const CRITERION_TEXT_RE = /^\s+- text:\s*"?(.+?)"?\s*$/gm;
|
|
20
|
+
/** Matches a BDD scenario's `- title: "..."` line (`bdd-parser.ts:renderBddScenariosYaml`) —
|
|
21
|
+
* the criterion-equivalent for `scenarios:`-format specs, which have no `- text:` entries. */
|
|
22
|
+
const SCENARIO_TITLE_RE = /^\s+- title:\s*"?(.+?)"?\s*$/gm;
|
|
3
23
|
const FILE_LINE_RE = /^-\s+(.+?)\s+\((pending|done)\)\s*$/gm;
|
|
24
|
+
const H3_HEADING_RE = /^###[ \t]+(.+?)[ \t]*$/gm;
|
|
25
|
+
const BULLET_LINE_RE = /^[-*][ \t]+(.+)$/;
|
|
26
|
+
/** A bullet that, once a leading `Label:`, wrapping backticks, and a trailing `(status)`
|
|
27
|
+
* marker are stripped, is just a file path: contains a `/` and ends in a short extension.
|
|
28
|
+
* File/artifact lists (`Files`, `Modify`, `Create`, `Test`, `File-Level Work Plan`, or any
|
|
29
|
+
* future heading with this shape) are legitimately parallel — the interpolation detector
|
|
30
|
+
* must not run on them. Detected structurally so no heading name needs hardcoding. */
|
|
31
|
+
const BULLET_LABEL_PREFIX_RE = /^[\w-]+:\s*/;
|
|
32
|
+
const STATUS_SUFFIX_RE = /\s*\([\w .-]+\)$/;
|
|
33
|
+
const PATH_TOKEN_RE = /^[\w./-]+\/[\w.-]+\.[a-zA-Z0-9]{1,10}$/;
|
|
34
|
+
/** Minimum normalized length before a variable substring can count as "restating" a
|
|
35
|
+
* criterion — guards against trivial short-string collisions. */
|
|
36
|
+
const CRITERION_RESTATEMENT_MIN_LENGTH = 8;
|
|
37
|
+
/**
|
|
38
|
+
* SPEC-1406 (DEFECT 2 fix): the constant kind reused to mark structural-interpolation
|
|
39
|
+
* findings so callers can tell them apart from the other generic-output issue kinds and
|
|
40
|
+
* decide, per call site, whether that finding blocks or only warns.
|
|
41
|
+
*/
|
|
42
|
+
export const STRUCTURAL_INTERPOLATION_KIND = 'structural-interpolation';
|
|
4
43
|
const GENERIC_CRITERION_RULES = [
|
|
5
44
|
{
|
|
6
45
|
pattern: /\bimplementation (is )?complete\b/i,
|
|
@@ -50,24 +89,176 @@ const PLACEHOLDER_REFERENCE_RULES = [
|
|
|
50
89
|
},
|
|
51
90
|
];
|
|
52
91
|
export function checkGenericSpecOutput(content) {
|
|
92
|
+
const criteria = frontmatterCriteria(content);
|
|
53
93
|
const issues = [
|
|
54
|
-
...checkCriteria(
|
|
94
|
+
...checkCriteria(criteria),
|
|
55
95
|
...checkTechnicalReferences(content),
|
|
96
|
+
...checkInterpolatedSections(content, criteria),
|
|
56
97
|
];
|
|
57
98
|
return { passed: issues.length === 0, issues };
|
|
58
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* SPEC-1406 (DEFECT 2, round 2 — detector rebuilt around CRITERION RESTATEMENT): the
|
|
102
|
+
* previous prefix/suffix-length rule flagged `### File-Level Work Plan` (whose bullets
|
|
103
|
+
* legitimately share a `- Modify: \`...\` (pending)` frame around a file path) and missed
|
|
104
|
+
* the actual filler this spec exists to kill (`- AC1 failure: ...`, `- Expected behavior
|
|
105
|
+
* AC1: ...`), because the framing on those templates is interrupted by a digit
|
|
106
|
+
* (`AC1` vs `AC2`) that a plain character-by-character prefix scan treats as a mismatch.
|
|
107
|
+
* The real discriminator isn't frame length — it's whether the varying middle of each
|
|
108
|
+
* bullet is itself a restatement of a declared acceptance criterion. Digit runs are
|
|
109
|
+
* treated as mutually equal while locating the shared frame, then the extracted middle
|
|
110
|
+
* is compared against every criterion.
|
|
111
|
+
*/
|
|
112
|
+
function checkInterpolatedSections(content, criteria) {
|
|
113
|
+
const issues = [];
|
|
114
|
+
const headingMatches = [...content.matchAll(H3_HEADING_RE)];
|
|
115
|
+
for (let i = 0; i < headingMatches.length; i += 1) {
|
|
116
|
+
const heading = headingMatches[i];
|
|
117
|
+
if (!heading?.[1]) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const start = heading.index + heading[0].length;
|
|
121
|
+
const end = headingMatches[i + 1]?.index ?? content.length;
|
|
122
|
+
const bullets = content
|
|
123
|
+
.slice(start, end)
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((line) => BULLET_LINE_RE.exec(line.trim())?.[1])
|
|
126
|
+
.filter((line) => Boolean(line));
|
|
127
|
+
if (isFileListSection(bullets)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (isInterpolatedTemplate(bullets, criteria)) {
|
|
131
|
+
issues.push({
|
|
132
|
+
kind: STRUCTURAL_INTERPOLATION_KIND,
|
|
133
|
+
phrase: heading[1],
|
|
134
|
+
reason: `subsection "${heading[1]}" restates each criterion into the same template with no new information`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return issues;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* SPEC-1406 (DEFECT 1, round 3 — file-list false positive): a section whose bullets are
|
|
142
|
+
* mostly file paths (`### Modify`, `### Files`, `### Create`, `### Test`, `### File-Level
|
|
143
|
+
* Work Plan`, ...) is an allowlisted shape, not prose — parallel paths under a shared
|
|
144
|
+
* directory/extension carry no information loss, even when a path also happens to appear
|
|
145
|
+
* inside a criterion's `FILES:` marker. Detected structurally (allowlist, not a heading
|
|
146
|
+
* name denylist) so an unanticipated heading defaults to safe.
|
|
147
|
+
*/
|
|
148
|
+
function isFileListSection(bullets) {
|
|
149
|
+
if (bullets.length === 0) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const pathLikeCount = bullets.filter(isPathLikeBullet).length;
|
|
153
|
+
return pathLikeCount > bullets.length / 2;
|
|
154
|
+
}
|
|
155
|
+
function isPathLikeBullet(bullet) {
|
|
156
|
+
const stripped = bullet
|
|
157
|
+
.trim()
|
|
158
|
+
.replace(BULLET_LABEL_PREFIX_RE, '')
|
|
159
|
+
.replace(STATUS_SUFFIX_RE, '')
|
|
160
|
+
.trim()
|
|
161
|
+
.replace(/^`|`$/g, '');
|
|
162
|
+
return PATH_TOKEN_RE.test(stripped);
|
|
163
|
+
}
|
|
164
|
+
function isInterpolatedTemplate(bullets, criteria) {
|
|
165
|
+
if (bullets.length < 2 || criteria.length === 0) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const first = bullets[0] ?? '';
|
|
169
|
+
const prefix = commonPrefixLength(bullets);
|
|
170
|
+
const suffix = commonSuffixLength(bullets, prefix);
|
|
171
|
+
if (prefix + suffix >= first.length) {
|
|
172
|
+
// Digit-tolerant framing consumed the entire bullet: the bullets are identical
|
|
173
|
+
// except for an index digit (e.g. `AC1` vs `AC2`) with no other variable content.
|
|
174
|
+
// The digit itself isn't the restated criterion — the digit-stripped body is.
|
|
175
|
+
return bullets.every((bullet) => restatesCriterion(stripDigits(bullet), criteria));
|
|
176
|
+
}
|
|
177
|
+
return bullets.every((bullet) => {
|
|
178
|
+
const variable = bullet.slice(prefix, bullet.length - suffix);
|
|
179
|
+
return restatesCriterion(variable, criteria);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function stripDigits(value) {
|
|
183
|
+
return value.replace(/\d+/g, '');
|
|
184
|
+
}
|
|
185
|
+
function restatesCriterion(variable, criteria) {
|
|
186
|
+
const normalizedVariable = normalizeForRestatementMatch(variable);
|
|
187
|
+
if (normalizedVariable.length < CRITERION_RESTATEMENT_MIN_LENGTH) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return criteria.some((criterion) => {
|
|
191
|
+
const normalizedCriterion = normalizeForRestatementMatch(criterion);
|
|
192
|
+
if (normalizedCriterion.length < CRITERION_RESTATEMENT_MIN_LENGTH) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return (normalizedVariable.includes(normalizedCriterion) ||
|
|
196
|
+
normalizedCriterion.includes(normalizedVariable));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function normalizeForRestatementMatch(value) {
|
|
200
|
+
return value
|
|
201
|
+
.replace(/[`*_"]/g, '')
|
|
202
|
+
.replace(/\s+/g, ' ')
|
|
203
|
+
.trim()
|
|
204
|
+
.toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
/** Digit runs are treated as mutually equal so a fixed template interrupted only by an
|
|
207
|
+
* `AC1`/`AC2`-style index still resolves to its true shared frame. */
|
|
208
|
+
function charsMatch(a, b) {
|
|
209
|
+
if (a === undefined || b === undefined) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return a === b || (isDigit(a) && isDigit(b));
|
|
213
|
+
}
|
|
214
|
+
function isDigit(ch) {
|
|
215
|
+
return ch >= '0' && ch <= '9';
|
|
216
|
+
}
|
|
217
|
+
function commonPrefixLength(values) {
|
|
218
|
+
const first = values[0] ?? '';
|
|
219
|
+
let length = first.length;
|
|
220
|
+
for (const value of values.slice(1)) {
|
|
221
|
+
let i = 0;
|
|
222
|
+
while (i < length && i < value.length && charsMatch(value[i], first[i])) {
|
|
223
|
+
i += 1;
|
|
224
|
+
}
|
|
225
|
+
length = Math.min(length, i);
|
|
226
|
+
}
|
|
227
|
+
return length;
|
|
228
|
+
}
|
|
229
|
+
function commonSuffixLength(values, limit) {
|
|
230
|
+
const first = values[0] ?? '';
|
|
231
|
+
let length = first.length - limit;
|
|
232
|
+
for (const value of values.slice(1)) {
|
|
233
|
+
let i = 0;
|
|
234
|
+
while (i < length &&
|
|
235
|
+
i < value.length - limit &&
|
|
236
|
+
charsMatch(value[value.length - 1 - i], first[first.length - 1 - i])) {
|
|
237
|
+
i += 1;
|
|
238
|
+
}
|
|
239
|
+
length = Math.min(length, i);
|
|
240
|
+
}
|
|
241
|
+
return Math.max(length, 0);
|
|
242
|
+
}
|
|
59
243
|
function frontmatterCriteria(content) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
244
|
+
const frontmatter = FRONTMATTER_RE.exec(content)?.[1] ?? '';
|
|
245
|
+
return [
|
|
246
|
+
...collectMatches(frontmatter, CRITERION_TEXT_RE),
|
|
247
|
+
...collectMatches(frontmatter, SCENARIO_TITLE_RE),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
function collectMatches(text, pattern) {
|
|
251
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
252
|
+
const values = [];
|
|
253
|
+
let match = re.exec(text);
|
|
63
254
|
while (match) {
|
|
64
255
|
const phrase = match[1]?.replace(/\\"/g, '"').trim() ?? '';
|
|
65
256
|
if (phrase.length > 0) {
|
|
66
|
-
|
|
257
|
+
values.push(phrase);
|
|
67
258
|
}
|
|
68
|
-
match =
|
|
259
|
+
match = re.exec(text);
|
|
69
260
|
}
|
|
70
|
-
return
|
|
261
|
+
return values;
|
|
71
262
|
}
|
|
72
263
|
function checkCriteria(criteria) {
|
|
73
264
|
const issues = [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// engine/spec-quality-scorer.ts — Spec quality scoring logic (SPEC-314)
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
|
|
4
|
+
import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
|
|
4
5
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
5
6
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
6
7
|
const MAX_DIMENSION_SCORE = 25;
|
|
@@ -67,11 +68,14 @@ async function readSpecContent(spec) {
|
|
|
67
68
|
async function readTechnicalContent(spec) {
|
|
68
69
|
return readSpecTechnicalSection(spec);
|
|
69
70
|
}
|
|
71
|
+
// Delegates to the canonical parser (SPEC-1410) instead of matching checkbox
|
|
72
|
+
// lines only, which returned [] for every GIVEN/WHEN/THEN spec. Deliberately
|
|
73
|
+
// does NOT reuse `readiness-checker.extractCriteriaLines`: that helper returns
|
|
74
|
+
// every non-empty line in the section (including WHEN/THEN/FILES: lines),
|
|
75
|
+
// which would inflate the denominator that scoreTestability/scoreAmbiguity
|
|
76
|
+
// divide by.
|
|
70
77
|
function extractCriteriaLines(content) {
|
|
71
|
-
return content
|
|
72
|
-
.split('\n')
|
|
73
|
-
.filter((line) => /^- \[[ x]\]/.test(line.trim()))
|
|
74
|
-
.map((line) => line.trim());
|
|
78
|
+
return extractAcceptanceCriteriaTexts(content);
|
|
75
79
|
}
|
|
76
80
|
// ── Dimension scorers ────────────────────────────────────────────────────────
|
|
77
81
|
function scoreCompleteness(spec, content) {
|
|
@@ -107,7 +111,7 @@ function scoreCompleteness(spec, content) {
|
|
|
107
111
|
}
|
|
108
112
|
else {
|
|
109
113
|
issues.push('No acceptance criteria found');
|
|
110
|
-
recommendations.push('Add acceptance criteria in
|
|
114
|
+
recommendations.push('Add acceptance criteria in GIVEN/WHEN/THEN format');
|
|
111
115
|
}
|
|
112
116
|
// Scope set to non-default (5 pts) — cast to handle partial specs at runtime
|
|
113
117
|
const scope = spec.scope;
|
|
@@ -10,6 +10,9 @@ import { planuApprovalGatesRule } from './rules/planu-approval-gates.js';
|
|
|
10
10
|
import { planuReleasePolicyRule } from './rules/planu-release-policy.js';
|
|
11
11
|
import { planuSddModelRoutingRule } from './rules/planu-sdd-model-routing.js';
|
|
12
12
|
import { planuMinimalChangeRule } from './rules/planu-minimal-change.js';
|
|
13
|
+
import { planuDebateReviewRule } from './rules/planu-debate-review.js';
|
|
14
|
+
import { planuRevertProofTestsRule } from './rules/planu-revert-proof-tests.js';
|
|
15
|
+
import { planuCleanCodeNoCommentsRule } from './rules/planu-clean-code-no-comments.js';
|
|
13
16
|
/**
|
|
14
17
|
* The full catalog of universal Planu rules.
|
|
15
18
|
* Order matters: rules are installed in catalog order.
|
|
@@ -25,5 +28,8 @@ export const UNIVERSAL_RULES = [
|
|
|
25
28
|
planuModesRule,
|
|
26
29
|
agentTeamsRule,
|
|
27
30
|
planuDogfoodBugsRule,
|
|
31
|
+
planuDebateReviewRule,
|
|
32
|
+
planuRevertProofTestsRule,
|
|
33
|
+
planuCleanCodeNoCommentsRule,
|
|
28
34
|
];
|
|
29
35
|
//# sourceMappingURL=catalog.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function buildBody() {
|
|
2
|
+
return `# Planu Clean Code — No Comments
|
|
3
|
+
|
|
4
|
+
Auto-generated by \`init_project\`. Do not edit manually.
|
|
5
|
+
|
|
6
|
+
Code must be self-documenting. A comment is allowed only when the behavior cannot be expressed through naming or structure — for example a non-obvious language semantic. Everything else is a naming problem.
|
|
7
|
+
|
|
8
|
+
This rule assumes \`planu-dogfood-bugs\` already prohibits deferred-work markers such as TODO/FIXME and does not restate that prohibition.
|
|
9
|
+
|
|
10
|
+
## The Rule
|
|
11
|
+
|
|
12
|
+
Prohibited: narrative comments that restate the code, \`(SPEC-NNNN: ...)\` rationale blocks whose history belongs in the spec and in git blame, step-by-step or section-divider comments inside a function, and JSDoc that only repeats the signature.
|
|
13
|
+
|
|
14
|
+
Replacement action: Extract a helper whose name is the comment, promote a magic value to a named constant, or introduce a named type when a shape needs explaining.
|
|
15
|
+
|
|
16
|
+
## Enforcement
|
|
17
|
+
|
|
18
|
+
Implementation agents add narrative comments by default. Stripping them before accepting subagent output is part of reviewing that output, not an optional cleanup pass.
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
export const planuCleanCodeNoCommentsRule = {
|
|
22
|
+
id: 'planu-clean-code-no-comments',
|
|
23
|
+
name: 'Planu Clean Code — No Comments',
|
|
24
|
+
description: 'Prohibits narrative comments and rationale blocks, requiring named helpers or constants instead.',
|
|
25
|
+
category: 'quality',
|
|
26
|
+
applicableHosts: ['all'],
|
|
27
|
+
defaultEnabled: true,
|
|
28
|
+
buildContent: (_host) => buildBody(),
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=planu-clean-code-no-comments.js.map
|