@planu/cli 5.3.66 → 5.3.68
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 +36 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/status.js +1 -1
- package/dist/config/release-policy.json +2 -2
- 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/dist/storage/global-projects-store.d.ts +17 -0
- package/dist/storage/global-projects-store.js +46 -0
- package/dist/storage/project-identity.d.ts +27 -0
- package/dist/storage/project-identity.js +131 -2
- package/dist/storage/spec-store.d.ts +24 -3
- package/dist/storage/spec-store.js +126 -5
- package/dist/tools/challenge-spec-helpers.d.ts +1 -1
- package/dist/tools/challenge-spec-helpers.js +7 -2
- package/dist/tools/challenge-spec.js +11 -6
- package/dist/tools/check-readiness.js +27 -4
- package/dist/tools/generate-execution-plan.js +10 -6
- package/dist/tools/update-status/evidence-gate.js +5 -5
- package/dist/tools/update-status/file-sync.js +11 -39
- package/dist/tools/update-status/index.js +1 -1
- package/dist/tools/update-status/transition-guard.d.ts +1 -1
- package/dist/tools/update-status/transition-guard.js +40 -8
- package/dist/types/cross-repo-search.d.ts +2 -0
- package/package.json +2 -2
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
## [5.3.68] - 2026-08-28
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- feat(SPEC-1666): produce source-quality receipt pre-bump with fail-fast gate order
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
- fix(SPEC-1668): scope metric masking, byte-equality guard and uniform anchors per dual review
|
|
8
|
+
- fix(SPEC-1668): bind product-proof bump regeneration fields and harden fixture commit retry
|
|
9
|
+
- fix(SPEC-1666): align release-pipeline recovery suite with bump-neutral receipt contract
|
|
10
|
+
- fix(SPEC-1666): byte-preserving carrier normalization with derivation checks and mode-bound masking
|
|
11
|
+
- fix(SPEC-1315): classify degraded reads in portable spec-path migration
|
|
12
|
+
- fix(SPEC-1315): verify canonical root and trigger migration on lifecycle reads
|
|
13
|
+
- fix(SPEC-1315): keep spec paths portable across worktrees and release clones
|
|
14
|
+
|
|
15
|
+
### Chores
|
|
16
|
+
- chore: absorb post-done session state
|
|
17
|
+
- chore(SPEC-1668): record done transition
|
|
18
|
+
- chore(SPEC-1668): record approval transition
|
|
19
|
+
- chore(SPEC-1666): record done transition
|
|
20
|
+
- chore(SPEC-1666): record implementing transition
|
|
21
|
+
- chore(SPEC-1315): record done transition and session state
|
|
22
|
+
- chore(specs): approve SPEC-1660..1664 with reviewer and discovery evidence
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## [5.3.67] - 2026-08-28
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
- fix(SPEC-1214): make readiness executable-evidence fallback reachable outside planu/specs
|
|
29
|
+
- fix(SPEC-1214): align readiness and validation on one executable-evidence contract
|
|
30
|
+
- fix(SPEC-1306): forward caller cwd as explicit projectPath in CLI status
|
|
31
|
+
|
|
32
|
+
### Chores
|
|
33
|
+
- chore(SPEC-1315): transition to implementing
|
|
34
|
+
- chore: mark SPEC-1306/SPEC-1214 done and file SPEC-1664 dogfood bug
|
|
35
|
+
|
|
36
|
+
|
|
1
37
|
## [5.3.66] - 2026-08-28
|
|
2
38
|
|
|
3
39
|
### Bug Fixes
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
1
|
+
{"schemaVersion":1,"commit":"39afacbb6522be60172b8571f6c5973000936a4a"}
|
|
@@ -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'],
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
"^src/config/release-policy\\.json$"
|
|
12
12
|
],
|
|
13
13
|
"sourceQualityPlan": [
|
|
14
|
-
{ "id": "validate", "executable": "pnpm", "args": ["validate"], "timeoutSeconds": 1800 },
|
|
15
|
-
{ "id": "strict", "executable": "pnpm", "args": ["check:strict"], "timeoutSeconds": 1800 },
|
|
16
14
|
{ "id": "reliability", "executable": "pnpm", "args": ["check:reliability"], "timeoutSeconds": 1800 },
|
|
17
15
|
{ "id": "dependency-freshness", "executable": "pnpm", "args": ["check:deps:fresh"], "timeoutSeconds": 1800 },
|
|
16
|
+
{ "id": "validate", "executable": "pnpm", "args": ["validate"], "timeoutSeconds": 1800 },
|
|
17
|
+
{ "id": "strict", "executable": "pnpm", "args": ["check:strict"], "timeoutSeconds": 1800 },
|
|
18
18
|
{ "id": "coverage", "executable": "pnpm", "args": ["test:coverage"], "timeoutSeconds": 1800 },
|
|
19
19
|
{ "id": "mutation", "executable": "pnpm", "args": ["audit:mutation"], "timeoutSeconds": 1800 }
|
|
20
20
|
]
|
|
@@ -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) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { RegisteredProject, GlobalProjectsRegistry } from '../types/index.js';
|
|
2
|
+
/** Best-effort, non-creating read of a checkout's logical project identity. */
|
|
3
|
+
export declare function readLogicalProjectId(projectPath: string): Promise<string | undefined>;
|
|
2
4
|
/**
|
|
3
5
|
* Read the full registry from disk.
|
|
4
6
|
* Returns an empty registry when the file does not exist.
|
|
@@ -15,6 +17,21 @@ export declare function saveRegistry(registry: GlobalProjectsRegistry): Promise<
|
|
|
15
17
|
* Returns the registered project entry.
|
|
16
18
|
*/
|
|
17
19
|
export declare function addProject(projectPath: string): Promise<RegisteredProject>;
|
|
20
|
+
/** Stable, classified failure for a logical project id with no unambiguous canonical root. */
|
|
21
|
+
export type CanonicalRootResult = {
|
|
22
|
+
readonly ok: true;
|
|
23
|
+
readonly root: string;
|
|
24
|
+
} | {
|
|
25
|
+
readonly ok: false;
|
|
26
|
+
readonly reason: 'zero' | 'ambiguous';
|
|
27
|
+
readonly roots: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the single canonical root registered for a logical project identity.
|
|
31
|
+
* Fails closed — never guesses a first match — when zero or more than one
|
|
32
|
+
* distinct registered path claims the same `logicalProjectId`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getCanonicalRoot(logicalProjectId: string): Promise<CanonicalRootResult>;
|
|
18
35
|
/**
|
|
19
36
|
* Remove a project from the registry by path.
|
|
20
37
|
* Returns true if the project was found and removed, false otherwise.
|
|
@@ -4,6 +4,23 @@ import { readJson, writeJson, globalDataDir, hashProjectPath } from './base-stor
|
|
|
4
4
|
import { withFileLock } from './file-mutex.js';
|
|
5
5
|
import { isEphemeralProject } from '../engine/data-projects-gc/pattern-matcher.js';
|
|
6
6
|
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
/** Best-effort, non-creating read of a checkout's logical project identity. */
|
|
11
|
+
export async function readLogicalProjectId(projectPath) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = JSON.parse(await readFile(join(projectPath, 'planu', 'project.json'), 'utf8'));
|
|
14
|
+
const id = raw?.logicalProjectId;
|
|
15
|
+
return typeof id === 'string' && UUID_PATTERN.test(id) ? id : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code !== 'ENOENT') {
|
|
19
|
+
reportClassifiedDegradation('LOGICAL_PROJECT_ID_READ_FAILED', error);
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
7
24
|
// ---------------------------------------------------------------------------
|
|
8
25
|
// File path
|
|
9
26
|
// ---------------------------------------------------------------------------
|
|
@@ -46,11 +63,15 @@ export async function addProject(projectPath) {
|
|
|
46
63
|
specCount: 0,
|
|
47
64
|
};
|
|
48
65
|
}
|
|
66
|
+
const logicalProjectId = await readLogicalProjectId(projectPath);
|
|
49
67
|
return withFileLock(registryFile(), async () => {
|
|
50
68
|
const registry = await getRegistry();
|
|
51
69
|
const existing = registry.projects.find((p) => p.path === projectPath);
|
|
52
70
|
if (existing !== undefined) {
|
|
53
71
|
existing.hash = hash;
|
|
72
|
+
if (logicalProjectId !== undefined) {
|
|
73
|
+
existing.logicalProjectId = logicalProjectId;
|
|
74
|
+
}
|
|
54
75
|
registry.updatedAt = new Date().toISOString();
|
|
55
76
|
await saveRegistry(registry);
|
|
56
77
|
return existing;
|
|
@@ -60,6 +81,7 @@ export async function addProject(projectPath) {
|
|
|
60
81
|
hash,
|
|
61
82
|
registeredAt: new Date().toISOString(),
|
|
62
83
|
specCount: 0,
|
|
84
|
+
...(logicalProjectId !== undefined && { logicalProjectId }),
|
|
63
85
|
};
|
|
64
86
|
registry.projects.push(entry);
|
|
65
87
|
registry.updatedAt = new Date().toISOString();
|
|
@@ -67,6 +89,30 @@ export async function addProject(projectPath) {
|
|
|
67
89
|
return entry;
|
|
68
90
|
});
|
|
69
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the single canonical root registered for a logical project identity.
|
|
94
|
+
* Fails closed — never guesses a first match — when zero or more than one
|
|
95
|
+
* distinct registered path claims the same `logicalProjectId`.
|
|
96
|
+
*/
|
|
97
|
+
export async function getCanonicalRoot(logicalProjectId) {
|
|
98
|
+
const projects = await getProjects();
|
|
99
|
+
const roots = [
|
|
100
|
+
...new Set(projects
|
|
101
|
+
.filter((project) => project.logicalProjectId === logicalProjectId)
|
|
102
|
+
.map((project) => project.path)),
|
|
103
|
+
];
|
|
104
|
+
if (roots.length === 0) {
|
|
105
|
+
return { ok: false, reason: 'zero', roots };
|
|
106
|
+
}
|
|
107
|
+
if (roots.length > 1) {
|
|
108
|
+
return { ok: false, reason: 'ambiguous', roots };
|
|
109
|
+
}
|
|
110
|
+
const [root] = roots;
|
|
111
|
+
if (root === undefined) {
|
|
112
|
+
return { ok: false, reason: 'zero', roots };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, root };
|
|
115
|
+
}
|
|
70
116
|
/**
|
|
71
117
|
* Remove a project from the registry by path.
|
|
72
118
|
* Returns true if the project was found and removed, false otherwise.
|
|
@@ -27,6 +27,33 @@ export declare function hashWorkspaceKey(workspaceKey: string): string;
|
|
|
27
27
|
export declare function deriveWorkspaceKey(projectPath: string): Promise<string>;
|
|
28
28
|
/** Resolve or create stable project and machine-workspace UUIDs. */
|
|
29
29
|
export declare function ensureProjectIdentity(projectPath: string, options: ProjectIdentityOptions): Promise<ResolvedProjectIdentity>;
|
|
30
|
+
export type PortablePathErrorCode = 'INVALID_TYPE' | 'EMPTY' | 'NUL_BYTE' | 'NOT_PORTABLE_SHAPE' | 'WRONG_SPEC' | 'ESCAPES_ROOT' | 'ROOT_NOT_FOUND' | 'ROOT_UNVERIFIED' | 'NOT_FOUND' | 'SYMLINK' | 'NOT_FILE';
|
|
31
|
+
/** Stable, classified failure for a spec path that cannot be resolved as a portable identity. */
|
|
32
|
+
export declare class PortablePathError extends Error {
|
|
33
|
+
readonly code: PortablePathErrorCode;
|
|
34
|
+
constructor(code: PortablePathErrorCode, message: string);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Extract the portable `planu/specs/<specId[-slug]>/<filename>` suffix from a stored
|
|
38
|
+
* path value, rejecting anything that is not that exact shape for the given spec.
|
|
39
|
+
* Works for both legacy absolute values and already-portable relative ones — the
|
|
40
|
+
* incoming prefix is discarded, so this doubles as the legacy-path normalizer.
|
|
41
|
+
*/
|
|
42
|
+
export declare function toPortableSpecPath(specId: string, storedPath: unknown): string;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a stored `specPath`/`technicalPath` value against the verified canonical
|
|
45
|
+
* root, returning the realpath of the contained file. Fails closed — with a stable
|
|
46
|
+
* classified {@link PortablePathError} — for any absent, malformed, escaping,
|
|
47
|
+
* symlinked, or cross-spec value, before any content is read or written.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolvePortableSpecPath(specId: string, storedPath: unknown, canonicalRoot: string): Promise<string>;
|
|
50
|
+
/**
|
|
51
|
+
* Lifecycle-consumer entry point: verify the candidate root against the
|
|
52
|
+
* global project registry before resolving through it, so an unregistered,
|
|
53
|
+
* ambiguous, or stale checkout never stands in for the real canonical root.
|
|
54
|
+
* Delegates to {@link resolvePortableSpecPath} once verified.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveVerifiedSpecPath(specId: string, storedPath: unknown, canonicalRoot: string): Promise<string>;
|
|
30
57
|
/** Explicitly turn a copied project into an independent logical project. */
|
|
31
58
|
export declare function forkProjectIdentity(projectPath: string, options: ProjectIdentityOptions): Promise<ResolvedProjectIdentity>;
|
|
32
59
|
//# sourceMappingURL=project-identity.d.ts.map
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname, join, normalize, resolve, sep } from 'node:path';
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { resolveStorageLayout } from './storage-layout.js';
|
|
5
|
+
import { getCanonicalRoot, readLogicalProjectId } from './global-projects-store.js';
|
|
6
|
+
import { isEphemeralProject } from '../engine/data-projects-gc/pattern-matcher.js';
|
|
5
7
|
const IDENTITY_VERSION = 1;
|
|
6
8
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
9
|
function isProjectIdentity(value) {
|
|
@@ -156,6 +158,133 @@ export async function ensureProjectIdentity(projectPath, options) {
|
|
|
156
158
|
const workspace = await ensureWorkspaceDocument(options.layout ?? resolveStorageLayout(), options.workspaceKey, project.logicalProjectId);
|
|
157
159
|
return { project, workspace, canonicalProjectPath };
|
|
158
160
|
}
|
|
161
|
+
/** Stable, classified failure for a spec path that cannot be resolved as a portable identity. */
|
|
162
|
+
export class PortablePathError extends Error {
|
|
163
|
+
code;
|
|
164
|
+
constructor(code, message) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = 'PortablePathError';
|
|
167
|
+
this.code = code;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Extract the portable `planu/specs/<specId[-slug]>/<filename>` suffix from a stored
|
|
172
|
+
* path value, rejecting anything that is not that exact shape for the given spec.
|
|
173
|
+
* Works for both legacy absolute values and already-portable relative ones — the
|
|
174
|
+
* incoming prefix is discarded, so this doubles as the legacy-path normalizer.
|
|
175
|
+
*/
|
|
176
|
+
export function toPortableSpecPath(specId, storedPath) {
|
|
177
|
+
if (typeof storedPath !== 'string') {
|
|
178
|
+
throw new PortablePathError('INVALID_TYPE', '[Planu] specPath must be a string');
|
|
179
|
+
}
|
|
180
|
+
if (storedPath.includes('\0')) {
|
|
181
|
+
throw new PortablePathError('NUL_BYTE', '[Planu] specPath must not contain a NUL byte');
|
|
182
|
+
}
|
|
183
|
+
if (storedPath.trim().length === 0) {
|
|
184
|
+
throw new PortablePathError('EMPTY', '[Planu] specPath must not be empty or whitespace-only');
|
|
185
|
+
}
|
|
186
|
+
const segments = normalize(storedPath).split(sep).filter(Boolean);
|
|
187
|
+
const planuIndex = segments.lastIndexOf('planu');
|
|
188
|
+
const dir = segments[planuIndex + 2];
|
|
189
|
+
const filename = segments[planuIndex + 3];
|
|
190
|
+
const isPortableShape = planuIndex >= 0 &&
|
|
191
|
+
segments[planuIndex + 1] === 'specs' &&
|
|
192
|
+
dir !== undefined &&
|
|
193
|
+
filename !== undefined &&
|
|
194
|
+
planuIndex + 4 === segments.length;
|
|
195
|
+
if (!isPortableShape) {
|
|
196
|
+
throw new PortablePathError('NOT_PORTABLE_SHAPE', '[Planu] specPath is not a portable planu/specs/<dir>/<file> contract');
|
|
197
|
+
}
|
|
198
|
+
if (dir !== specId && !dir.startsWith(`${specId}-`)) {
|
|
199
|
+
throw new PortablePathError('WRONG_SPEC', `[Planu] specPath directory "${dir}" does not belong to ${specId}`);
|
|
200
|
+
}
|
|
201
|
+
return join('planu', 'specs', dir, filename);
|
|
202
|
+
}
|
|
203
|
+
function rejectsRootEscape(root, candidate) {
|
|
204
|
+
const rel = relative(root, candidate);
|
|
205
|
+
return rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel);
|
|
206
|
+
}
|
|
207
|
+
async function realpathClassified(path, notFoundCode) {
|
|
208
|
+
try {
|
|
209
|
+
return await realpath(path);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
213
|
+
throw new PortablePathError(notFoundCode, `[Planu] path not found: ${notFoundCode}`);
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Reject a candidate root that carries a tracked logical project identity but
|
|
220
|
+
* is not the single registry-confirmed canonical root for it — a checkout
|
|
221
|
+
* left over from a rename/fork, or an ambiguous/unregistered project never
|
|
222
|
+
* resolves specs against an arbitrary directory. A candidate with no tracked
|
|
223
|
+
* identity (`planu/project.json` absent) is untracked, not unverified, and
|
|
224
|
+
* passes through unchanged — as does an ephemeral checkout (test fixture,
|
|
225
|
+
* scratch clone), since the global registry deliberately excludes those
|
|
226
|
+
* (SPEC-581) and can never confirm a root for them.
|
|
227
|
+
*/
|
|
228
|
+
async function rejectsUnverifiedRoot(root) {
|
|
229
|
+
if (isEphemeralProject(root)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
const logicalProjectId = await readLogicalProjectId(root);
|
|
233
|
+
if (logicalProjectId === undefined) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const canonical = await getCanonicalRoot(logicalProjectId);
|
|
237
|
+
return !canonical.ok || resolve(canonical.root) !== root;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Resolve a stored `specPath`/`technicalPath` value against the verified canonical
|
|
241
|
+
* root, returning the realpath of the contained file. Fails closed — with a stable
|
|
242
|
+
* classified {@link PortablePathError} — for any absent, malformed, escaping,
|
|
243
|
+
* symlinked, or cross-spec value, before any content is read or written.
|
|
244
|
+
*/
|
|
245
|
+
export async function resolvePortableSpecPath(specId, storedPath, canonicalRoot) {
|
|
246
|
+
const suffix = toPortableSpecPath(specId, storedPath);
|
|
247
|
+
const root = resolve(canonicalRoot);
|
|
248
|
+
const candidate = resolve(root, suffix);
|
|
249
|
+
if (rejectsRootEscape(root, candidate)) {
|
|
250
|
+
throw new PortablePathError('ESCAPES_ROOT', '[Planu] specPath escapes the canonical root');
|
|
251
|
+
}
|
|
252
|
+
const rootReal = await realpathClassified(root, 'ROOT_NOT_FOUND');
|
|
253
|
+
let candidateReal;
|
|
254
|
+
let candidateStat;
|
|
255
|
+
try {
|
|
256
|
+
candidateReal = await realpathClassified(candidate, 'NOT_FOUND');
|
|
257
|
+
candidateStat = await lstat(candidate);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
261
|
+
throw new PortablePathError('NOT_FOUND', '[Planu] spec file not found under canonical root');
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
if (candidateStat.isSymbolicLink()) {
|
|
266
|
+
throw new PortablePathError('SYMLINK', '[Planu] specPath must not resolve through a symlink');
|
|
267
|
+
}
|
|
268
|
+
if (!candidateStat.isFile()) {
|
|
269
|
+
throw new PortablePathError('NOT_FILE', '[Planu] specPath must resolve to a regular file');
|
|
270
|
+
}
|
|
271
|
+
if (rejectsRootEscape(rootReal, candidateReal)) {
|
|
272
|
+
throw new PortablePathError('ESCAPES_ROOT', '[Planu] specPath escapes the canonical root');
|
|
273
|
+
}
|
|
274
|
+
return candidateReal;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Lifecycle-consumer entry point: verify the candidate root against the
|
|
278
|
+
* global project registry before resolving through it, so an unregistered,
|
|
279
|
+
* ambiguous, or stale checkout never stands in for the real canonical root.
|
|
280
|
+
* Delegates to {@link resolvePortableSpecPath} once verified.
|
|
281
|
+
*/
|
|
282
|
+
export async function resolveVerifiedSpecPath(specId, storedPath, canonicalRoot) {
|
|
283
|
+
if (await rejectsUnverifiedRoot(resolve(canonicalRoot))) {
|
|
284
|
+
throw new PortablePathError('ROOT_UNVERIFIED', '[Planu] canonical root is not the single registry-confirmed root for this project');
|
|
285
|
+
}
|
|
286
|
+
return resolvePortableSpecPath(specId, storedPath, canonicalRoot);
|
|
287
|
+
}
|
|
159
288
|
/** Explicitly turn a copied project into an independent logical project. */
|
|
160
289
|
export async function forkProjectIdentity(projectPath, options) {
|
|
161
290
|
const canonicalProjectPath = await canonicalizeProjectPath(projectPath);
|