@planu/cli 5.3.65 → 5.3.67
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 +18 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/status.js +1 -1
- package/dist/engine/readiness-checker.js +20 -0
- package/dist/engine/validator/executable-evidence.d.ts +31 -0
- package/dist/engine/validator/executable-evidence.js +104 -0
- package/dist/engine/validator/spec-compliance-runner.d.ts +2 -0
- package/dist/engine/validator/spec-compliance-runner.js +21 -3
- package/dist/engine/validator/validation-report-writer.js +10 -4
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [5.3.67] - 2026-08-28
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(SPEC-1214): make readiness executable-evidence fallback reachable outside planu/specs
|
|
5
|
+
- fix(SPEC-1214): align readiness and validation on one executable-evidence contract
|
|
6
|
+
- fix(SPEC-1306): forward caller cwd as explicit projectPath in CLI status
|
|
7
|
+
|
|
8
|
+
### Chores
|
|
9
|
+
- chore(SPEC-1315): transition to implementing
|
|
10
|
+
- chore: mark SPEC-1306/SPEC-1214 done and file SPEC-1664 dogfood bug
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [5.3.66] - 2026-08-28
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
- fix(SPEC-1314): distinguish registry metadata from publish manifests in privacy gates
|
|
17
|
+
|
|
18
|
+
|
|
1
19
|
## [5.3.65] - 2026-08-28
|
|
2
20
|
|
|
3
21
|
### Bug Fixes
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
1
|
+
{"schemaVersion":1,"commit":"fde3438aa03084c32064ee5f7ec4179e455e6062"}
|
|
@@ -58,7 +58,7 @@ export function buildSingleStatusInput(values, specId, projectId, status) {
|
|
|
58
58
|
return {
|
|
59
59
|
specId,
|
|
60
60
|
projectId,
|
|
61
|
-
projectPath: values['project-path'] ? resolve(values['project-path']) :
|
|
61
|
+
projectPath: values['project-path'] ? resolve(values['project-path']) : process.cwd(),
|
|
62
62
|
status: status,
|
|
63
63
|
reviewNotes: values.notes,
|
|
64
64
|
modelId: values['model-id'],
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// engine/readiness-checker.ts — Spec readiness evaluation for implementation gate (SPEC-039)
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { extractSectionBody, readSpecTechnicalSection, } from './spec-format/read-technical-section.js';
|
|
4
6
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
5
7
|
import { loadReadinessConfig } from './readiness-config-loader.js';
|
|
6
8
|
import { runEarsGate } from './ears-gate.js';
|
|
7
9
|
import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validator/spec-compliance-runner.js';
|
|
10
|
+
import { normalizeExecutableEvidence } from './validator/executable-evidence.js';
|
|
8
11
|
import { evaluateImplementationContract } from './implementation-contract/index.js';
|
|
9
12
|
import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
|
|
10
13
|
// ── SPEC-784: Technical section quality constants ─────────────────────────────
|
|
@@ -33,6 +36,13 @@ function scoreHuCompleteness(spec) {
|
|
|
33
36
|
}
|
|
34
37
|
return { points, blockers, warnings };
|
|
35
38
|
}
|
|
39
|
+
function projectPathFromSpecPath(specPath) {
|
|
40
|
+
const marker = /[/\\]planu[/\\]specs[/\\]/;
|
|
41
|
+
if (!specPath || !marker.test(specPath)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return specPath.split(marker)[0];
|
|
45
|
+
}
|
|
36
46
|
async function readHuRaw(spec) {
|
|
37
47
|
if (!spec.specPath) {
|
|
38
48
|
return '';
|
|
@@ -421,6 +431,16 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
|
|
|
421
431
|
allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries (SPEC-732)`);
|
|
422
432
|
}
|
|
423
433
|
}
|
|
434
|
+
// SPEC-1214: BDD-criteria executable-evidence gate — when a spec has no frontmatter
|
|
435
|
+
// scenarios, its criteria must carry a TEST: marker mapping to an executable test file,
|
|
436
|
+
// or validation will later score 0 with "No executable scenarios declared".
|
|
437
|
+
if (bddScenarioCount === 0 && spec.scope !== 'trivial') {
|
|
438
|
+
const readinessProjectPath = projectPathFromSpecPath(spec.specPath);
|
|
439
|
+
const executableEvidence = normalizeExecutableEvidence(huRaw, parseFrontmatterScenarios(huRaw), (path) => (readinessProjectPath ? existsSync(join(readinessProjectPath, path)) : true));
|
|
440
|
+
for (const title of executableEvidence.unmappedCriteria) {
|
|
441
|
+
allBlockers.push(`criteria_missing_executable_test: criterion "${title}" has no TEST: marker mapping to an executable test file (SPEC-1214)`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
424
444
|
// SPEC-784: Technical section quality gates — blocker-severity issues
|
|
425
445
|
if (detectTechnicalPlaceholder(huContent)) {
|
|
426
446
|
allBlockers.push('TECHNICAL_PLACEHOLDER_DETECTED: ## Technical section contains a placeholder pointer ' +
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface EvidenceTestLink {
|
|
2
|
+
path: string;
|
|
3
|
+
}
|
|
4
|
+
export interface EvidenceScenario {
|
|
5
|
+
title: string;
|
|
6
|
+
tests: EvidenceTestLink[];
|
|
7
|
+
}
|
|
8
|
+
export type ExecutableEvidenceSource = 'frontmatter' | 'criteria' | 'none';
|
|
9
|
+
export interface ExecutableEvidence {
|
|
10
|
+
source: ExecutableEvidenceSource;
|
|
11
|
+
scenarios: EvidenceScenario[];
|
|
12
|
+
unmappedCriteria: string[];
|
|
13
|
+
ignoredCriteriaTitles: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Single evidence normalizer shared by spec-compliance-runner.ts and
|
|
17
|
+
* readiness-checker.ts (SPEC-1214). Frontmatter `scenarios` stay canonical when
|
|
18
|
+
* present; otherwise BDD criteria `TEST:` markers become the executable evidence,
|
|
19
|
+
* so a spec approved by readiness on `TEST:` markers cannot later validate as
|
|
20
|
+
* "no executable scenarios declared". `isValidTestPath` lets each caller reject a
|
|
21
|
+
* `TEST:` marker pointing at a file that does not exist on disk, instead of
|
|
22
|
+
* silently treating it as executable evidence; it defaults to accepting every
|
|
23
|
+
* path when a caller has no filesystem to check against.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeExecutableEvidence(raw: string, frontmatterScenarios: {
|
|
26
|
+
title: string;
|
|
27
|
+
tests?: {
|
|
28
|
+
path: string;
|
|
29
|
+
}[];
|
|
30
|
+
}[], isValidTestPath?: (path: string) => boolean): ExecutableEvidence;
|
|
31
|
+
//# sourceMappingURL=executable-evidence.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { extractSection } from '../spec-format/markdown-sections.js';
|
|
2
|
+
import { stripFrontmatter } from '../frontmatter-parser.js';
|
|
3
|
+
const CRITERION_HEADING = /^#{2,6}\s+((?:AC|AB)\s*-?\s*\d+)\b/i;
|
|
4
|
+
const GIVEN_BULLET = /^-\s*GIVEN\b/i;
|
|
5
|
+
const BDD_STEP = /^(?:[-*]\s+)?(GIVEN|WHEN|THEN|AND)\b/i;
|
|
6
|
+
const TEST_MARKER_LINE = /^\s*TEST:\s*(.+)$/im;
|
|
7
|
+
const TEST_FILE_TOKEN = /`?([\w./-]+\.(?:test|spec)\.[cm]?[jt]sx?)`?/g;
|
|
8
|
+
function splitByLineStarts(lines, starts) {
|
|
9
|
+
return starts.map((start, position) => {
|
|
10
|
+
const end = starts[position + 1] ?? lines.length;
|
|
11
|
+
return lines.slice(start, end).join('\n');
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function splitCriterionBlocks(section) {
|
|
15
|
+
const lines = section.split('\n');
|
|
16
|
+
const headingStarts = lines
|
|
17
|
+
.map((line, index) => (CRITERION_HEADING.test(line.trim()) ? index : -1))
|
|
18
|
+
.filter((index) => index !== -1);
|
|
19
|
+
if (headingStarts.length > 0) {
|
|
20
|
+
return splitByLineStarts(lines, headingStarts);
|
|
21
|
+
}
|
|
22
|
+
const bulletStarts = lines
|
|
23
|
+
.map((line, index) => (GIVEN_BULLET.test(line.trim()) ? index : -1))
|
|
24
|
+
.filter((index) => index !== -1);
|
|
25
|
+
if (bulletStarts.length > 0) {
|
|
26
|
+
return splitByLineStarts(lines, bulletStarts);
|
|
27
|
+
}
|
|
28
|
+
return section.split(/\n\s*\n/).filter((block) => /\bGIVEN\b/i.test(block));
|
|
29
|
+
}
|
|
30
|
+
function blockTitle(block) {
|
|
31
|
+
const stepLines = block
|
|
32
|
+
.split('\n')
|
|
33
|
+
.map((line) => line.trim())
|
|
34
|
+
.filter((line) => BDD_STEP.test(line))
|
|
35
|
+
.map((line) => line.replace(/^[-*]\s+/, ''));
|
|
36
|
+
if (stepLines.length > 0) {
|
|
37
|
+
return stepLines.join(' ');
|
|
38
|
+
}
|
|
39
|
+
const heading = /^#{2,6}\s+(.+)$/.exec(block.split('\n')[0]?.trim() ?? '');
|
|
40
|
+
return (heading?.[1] ?? block.trim()).slice(0, 200);
|
|
41
|
+
}
|
|
42
|
+
function blockTestLinks(block, isValidTestPath) {
|
|
43
|
+
const marker = TEST_MARKER_LINE.exec(block);
|
|
44
|
+
if (!marker?.[1]) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return [...marker[1].matchAll(TEST_FILE_TOKEN)]
|
|
48
|
+
.map((match) => match[1])
|
|
49
|
+
.filter((path) => Boolean(path))
|
|
50
|
+
.filter(isValidTestPath)
|
|
51
|
+
.map((path) => ({ path }));
|
|
52
|
+
}
|
|
53
|
+
function extractCriteriaScenarios(raw, isValidTestPath) {
|
|
54
|
+
const body = stripFrontmatter(raw);
|
|
55
|
+
const section = extractSection(body, 'acceptance criteria', 'criterios de aceptaci') ?? body;
|
|
56
|
+
return splitCriterionBlocks(section).map((block) => ({
|
|
57
|
+
title: blockTitle(block),
|
|
58
|
+
tests: blockTestLinks(block, isValidTestPath),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Single evidence normalizer shared by spec-compliance-runner.ts and
|
|
63
|
+
* readiness-checker.ts (SPEC-1214). Frontmatter `scenarios` stay canonical when
|
|
64
|
+
* present; otherwise BDD criteria `TEST:` markers become the executable evidence,
|
|
65
|
+
* so a spec approved by readiness on `TEST:` markers cannot later validate as
|
|
66
|
+
* "no executable scenarios declared". `isValidTestPath` lets each caller reject a
|
|
67
|
+
* `TEST:` marker pointing at a file that does not exist on disk, instead of
|
|
68
|
+
* silently treating it as executable evidence; it defaults to accepting every
|
|
69
|
+
* path when a caller has no filesystem to check against.
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeExecutableEvidence(raw, frontmatterScenarios, isValidTestPath = () => true) {
|
|
72
|
+
const criteriaScenarios = extractCriteriaScenarios(raw, isValidTestPath);
|
|
73
|
+
if (frontmatterScenarios.length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
source: 'frontmatter',
|
|
76
|
+
scenarios: frontmatterScenarios.map((scenario) => ({
|
|
77
|
+
title: scenario.title,
|
|
78
|
+
tests: (scenario.tests ?? []).map((test) => ({ path: test.path })),
|
|
79
|
+
})),
|
|
80
|
+
unmappedCriteria: [],
|
|
81
|
+
ignoredCriteriaTitles: criteriaScenarios
|
|
82
|
+
.filter((scenario) => scenario.tests.length > 0)
|
|
83
|
+
.map((scenario) => scenario.title),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const mapped = criteriaScenarios.filter((scenario) => scenario.tests.length > 0);
|
|
87
|
+
if (mapped.length > 0) {
|
|
88
|
+
return {
|
|
89
|
+
source: 'criteria',
|
|
90
|
+
scenarios: criteriaScenarios,
|
|
91
|
+
unmappedCriteria: criteriaScenarios
|
|
92
|
+
.filter((scenario) => scenario.tests.length === 0)
|
|
93
|
+
.map((scenario) => scenario.title),
|
|
94
|
+
ignoredCriteriaTitles: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
source: 'none',
|
|
99
|
+
scenarios: [],
|
|
100
|
+
unmappedCriteria: criteriaScenarios.map((scenario) => scenario.title),
|
|
101
|
+
ignoredCriteriaTitles: [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=executable-evidence.js.map
|
|
@@ -27,6 +27,8 @@ export interface SpecComplianceResult {
|
|
|
27
27
|
dimensionScore: number;
|
|
28
28
|
perScenario: PerScenarioResult[];
|
|
29
29
|
command: string;
|
|
30
|
+
evidenceSource?: 'frontmatter' | 'criteria' | 'none';
|
|
31
|
+
ignoredCriteriaTitles?: string[];
|
|
30
32
|
}
|
|
31
33
|
/** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
|
|
32
34
|
export declare class ComplianceCommandTerminalError extends Error {
|
|
@@ -8,6 +8,7 @@ import { technologyValue } from '../technology-registry.js';
|
|
|
8
8
|
import { readMatchingValidationEvidence } from '../validation-evidence-ledger.js';
|
|
9
9
|
import { readEvidenceArtifacts, traceabilityRowHasCurrentCommandEvidence, } from '../evidence-gates/artifact-reader.js';
|
|
10
10
|
import { normalizeCriterionText } from '../criterion-identity.js';
|
|
11
|
+
import { normalizeExecutableEvidence } from './executable-evidence.js';
|
|
11
12
|
/** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
|
|
12
13
|
export class ComplianceCommandTerminalError extends Error {
|
|
13
14
|
code;
|
|
@@ -450,16 +451,31 @@ async function loadExactCommandRows(spec, projectPath, canonicalProjectId) {
|
|
|
450
451
|
// eslint-disable-next-line max-lines-per-function -- coordinates validation, adapters, and isolated per-scenario evidence
|
|
451
452
|
export async function runSpecCompliance(spec, projectPath, signal, canonicalProjectId) {
|
|
452
453
|
const { readFile } = await import('node:fs/promises');
|
|
454
|
+
const { existsSync } = await import('node:fs');
|
|
453
455
|
let raw;
|
|
454
456
|
try {
|
|
455
457
|
raw = await readFile(spec.specPath, 'utf-8');
|
|
456
458
|
}
|
|
457
459
|
catch {
|
|
458
|
-
return { dimensionScore: 0, perScenario: [], command: '' };
|
|
460
|
+
return { dimensionScore: 0, perScenario: [], command: '', evidenceSource: 'none' };
|
|
459
461
|
}
|
|
460
|
-
const
|
|
462
|
+
const frontmatterScenarios = parseFrontmatterScenarios(raw);
|
|
463
|
+
const executableEvidence = normalizeExecutableEvidence(raw, frontmatterScenarios, (path) => existsSync(join(projectPath, path)));
|
|
464
|
+
const scenarios = executableEvidence.source === 'frontmatter'
|
|
465
|
+
? frontmatterScenarios
|
|
466
|
+
: executableEvidence.scenarios.map((scenario) => ({
|
|
467
|
+
title: scenario.title,
|
|
468
|
+
done: false,
|
|
469
|
+
tests: scenario.tests.map((test) => ({ path: test.path })).filter(isExecutableTestLink),
|
|
470
|
+
}));
|
|
461
471
|
if (scenarios.length === 0) {
|
|
462
|
-
return {
|
|
472
|
+
return {
|
|
473
|
+
dimensionScore: 0,
|
|
474
|
+
perScenario: [],
|
|
475
|
+
command: '',
|
|
476
|
+
evidenceSource: executableEvidence.source,
|
|
477
|
+
ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
|
|
478
|
+
};
|
|
463
479
|
}
|
|
464
480
|
const allLinks = scenarios.flatMap((scenario) => scenario.tests ?? []);
|
|
465
481
|
const executableLinks = allLinks.filter(isExecutableTestLink);
|
|
@@ -570,6 +586,8 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
|
|
|
570
586
|
perScenario,
|
|
571
587
|
command: commands.join(' && ') ||
|
|
572
588
|
`${technologyValue('technology-vitest-a9127f')} --reporter=json --run (no test files linked)`,
|
|
589
|
+
evidenceSource: executableEvidence.source,
|
|
590
|
+
ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
|
|
573
591
|
};
|
|
574
592
|
}
|
|
575
593
|
export function findScenariosWithoutTests(raw) {
|
|
@@ -130,12 +130,18 @@ function buildMinimalityGate(report) {
|
|
|
130
130
|
: undefined,
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
+
function ignoredCriteriaNote(result) {
|
|
134
|
+
return result.ignoredCriteriaTitles?.length
|
|
135
|
+
? ` Ignored BDD criteria TEST: mappings (frontmatter scenarios are canonical): ${result.ignoredCriteriaTitles.join(', ')}.`
|
|
136
|
+
: '';
|
|
137
|
+
}
|
|
133
138
|
function buildSpecComplianceGate(result) {
|
|
139
|
+
const ignoredNote = ignoredCriteriaNote(result);
|
|
134
140
|
if (result.perScenario.length === 0) {
|
|
135
141
|
return {
|
|
136
142
|
name: 'spec-compliance',
|
|
137
|
-
passed:
|
|
138
|
-
reason:
|
|
143
|
+
passed: false,
|
|
144
|
+
reason: `No executable scenarios declared (evidence source: ${result.evidenceSource ?? 'none'}) — add BDD criteria TEST: markers or frontmatter scenarios before done.${ignoredNote}`,
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
const passed = result.dimensionScore === 100;
|
|
@@ -143,8 +149,8 @@ function buildSpecComplianceGate(result) {
|
|
|
143
149
|
name: 'spec-compliance',
|
|
144
150
|
passed,
|
|
145
151
|
reason: passed
|
|
146
|
-
? undefined
|
|
147
|
-
: `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}
|
|
152
|
+
? ignoredNote.trim() || undefined
|
|
153
|
+
: `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}.${ignoredNote}`,
|
|
148
154
|
};
|
|
149
155
|
}
|
|
150
156
|
function toValidationReportCompliance(result) {
|
package/package.json
CHANGED
package/planu-plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dev.planu.cli",
|
|
3
3
|
"displayName": "Planu — Spec Driven Development",
|
|
4
4
|
"description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
|
|
5
|
-
"version": "5.3.
|
|
5
|
+
"version": "5.3.67",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|