Skip to content

Commit 0aa4a97

Browse files
authored
Fix lifecycle artifact and health truth parsing (#92)
* fix: harden lifecycle and health truth parsing * fix: keep health tied to lifecycle artifacts
1 parent aff3d0f commit 0aa4a97

6 files changed

Lines changed: 275 additions & 15 deletions

File tree

bin/lib/health-truth.mjs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, option
5656
warnings.push({
5757
id: 'W8',
5858
severity: 'WARN',
59-
message: `distilled/README.md workflow inventory is out of sync (${issues.join('; ')})`,
60-
fix: 'Update distilled/README.md workflow status table and framework file tree to match distilled/workflows/',
59+
message: `distilled/README.md workflow surface/status inventory is out of sync (${issues.join('; ')})`,
60+
fix: 'Update distilled/README.md workflow inventory table and framework file tree to match distilled/workflows/',
6161
});
6262
}
6363
}
@@ -119,19 +119,34 @@ function extractHealthTableIds(content) {
119119
}
120120

121121
function extractReadmeStatusEntries(content) {
122-
const section = extractSection(content, '## Current Status', 'Architecture notes:');
122+
const workflowSurface = extractSection(content, '## Workflow Surface', 'Architecture notes:');
123+
const currentStatus = extractSection(content, '## Current Status', 'Architecture notes:');
124+
const section = workflowSurface || currentStatus;
123125
if (!section) return [];
124126
return [...normalizeContent(section).matchAll(/\|\s*`([^`]+\.md)`\s*\|/g)].map((result) => result[1]);
125127
}
126128

127129
function extractReadmeWorkflowTreeEntries(content) {
128130
const section = extractSection(content, '## Files In This Framework', '## ');
129-
const match = normalizeContent(section || '').match(/```[\s\S]*?workflows\/\n([\s\S]*?)\n\s*templates\//);
131+
const match = normalizeContent(section || '').match(/```[^\n]*\n([\s\S]*?)\n```/);
130132
if (!match) return [];
131-
return match[1]
132-
.split('\n')
133-
.map((line) => line.trim())
134-
.filter((line) => line.endsWith('.md'));
133+
const entries = [];
134+
let inWorkflows = false;
135+
for (const rawLine of match[1].split('\n')) {
136+
const line = rawLine.trim();
137+
if (!line) continue;
138+
if (!inWorkflows) {
139+
if (/\bworkflows\/$/.test(line)) inWorkflows = true;
140+
continue;
141+
}
142+
if (/\.md\b/.test(line)) {
143+
const file = line.match(/([^\s/`|]+\.md)\b/);
144+
if (file) entries.push(file[1]);
145+
continue;
146+
}
147+
if (/\b[^\s/]+\/$/.test(line)) break;
148+
}
149+
return entries;
135150
}
136151

137152
function extractRepoLocalPaths(content) {

bin/lib/health.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function createCmdHealth(ctx) {
179179
const lifecycle = evaluateLifecycleState({ planningDir });
180180

181181
if (roadmap && existsSync(phasesDir)) {
182-
for (const phase of lifecycle.phases.filter((entry) => entry.status !== 'not_started' && !entry.hasArtifacts)) {
182+
for (const phase of lifecycle.phases.filter((entry) => entry.status !== 'not_started' && !entry.hasLifecycleArtifacts)) {
183183
warnings.push({
184184
id: 'W4',
185185
severity: 'WARN',

bin/lib/lifecycle-state.mjs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {})
4242
return {
4343
...phase,
4444
hasArtifacts: matchingArtifacts.length > 0,
45+
hasLifecycleArtifacts: hasPlan || hasSummary,
4546
hasPlan,
4647
hasSummary,
4748
artifacts: matchingArtifacts,
@@ -339,10 +340,10 @@ function classifyPhaseArtifact(dir, name) {
339340
if (!baseIdMatch || !phaseTokenMatch) return null;
340341

341342
let kind = 'other';
342-
if (name.includes('PLAN')) kind = 'plan';
343-
else if (name.includes('SUMMARY')) kind = 'summary';
344-
else if (name.includes('VERIFICATION')) kind = 'verification';
345-
else if (name.includes('APPROACH')) kind = 'approach';
343+
if (isNamedPhaseArtifact(name, baseIdMatch[1], 'PLAN')) kind = 'plan';
344+
else if (isNamedPhaseArtifact(name, baseIdMatch[1], 'SUMMARY')) kind = 'summary';
345+
else if (isNamedPhaseArtifact(name, baseIdMatch[1], 'VERIFICATION')) kind = 'verification';
346+
else if (isNamedPhaseArtifact(name, baseIdMatch[1], 'APPROACH')) kind = 'approach';
346347

347348
return {
348349
dir,
@@ -354,6 +355,10 @@ function classifyPhaseArtifact(dir, name) {
354355
};
355356
}
356357

358+
function isNamedPhaseArtifact(name, baseId, kind) {
359+
return name.toLowerCase() === `${baseId.toLowerCase()}-${kind.toLowerCase()}.md`;
360+
}
361+
357362
function parseMilestoneLedger(content) {
358363
if (!content) return [];
359364

bin/lib/phase.mjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@ function listPhaseArtifacts(dir) {
7272
}
7373

7474
function classifyPhaseArtifact(dir, name) {
75+
const baseIdMatch = name.match(/^(\d+(?:\.\d+)*[a-z]?(?:-\d+)?)/i);
7576
const dirMatch = dir ? dir.match(/^(\d+(?:\.\d+)*[a-z]?)-/i) : null;
7677
const nameMatch = name.match(/^(\d+(?:\.\d+)*[a-z]?)/i);
7778
const phaseToken = normalizePhaseToken((dirMatch || nameMatch)?.[1] || '');
7879

7980
let kind = 'OTHER';
80-
if (name.includes('PLAN')) kind = 'PLAN';
81-
else if (name.includes('SUMMARY')) kind = 'SUMMARY';
81+
if (baseIdMatch && isNamedPhaseArtifact(name, baseIdMatch[1], 'PLAN')) kind = 'PLAN';
82+
else if (baseIdMatch && isNamedPhaseArtifact(name, baseIdMatch[1], 'SUMMARY')) kind = 'SUMMARY';
8283

8384
return {
8485
dir,
@@ -89,6 +90,10 @@ function classifyPhaseArtifact(dir, name) {
8990
};
9091
}
9192

93+
function isNamedPhaseArtifact(name, baseId, kind) {
94+
return name.toLowerCase() === `${baseId.toLowerCase()}-${kind.toLowerCase()}.md`;
95+
}
96+
9297
function padPhase(n) {
9398
return String(n).padStart(2, '0');
9499
}

tests/gsdd.health.test.cjs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,34 @@ function writeAlignedTruthFixtures() {
9393
writeFile('.planning/ROADMAP.md', '- [ ] **Phase 16: Framework Health & Truth Reconciliation** — [LAUNCH-07]\n');
9494
}
9595

96+
function writeWorkflowInventoryReadme({ heading = '## Workflow Surface', rows = ['alpha.md', 'beta.md'], treeLines }) {
97+
const tableIntro = heading.startsWith('## Current Status')
98+
? ['| Workflow | Status | Notes |', '|----------|--------|-------|']
99+
: ['| Workflow | What ships |', '|----------|------------|'];
100+
const tableRows = rows.map((file) => `| \`${file}\` | x |`);
101+
writeFile('distilled/README.md', [
102+
heading,
103+
'',
104+
...tableIntro,
105+
...tableRows,
106+
'',
107+
'Architecture notes:',
108+
'',
109+
'## Files In This Framework',
110+
'',
111+
'```',
112+
...(treeLines || [
113+
'distilled/',
114+
' workflows/',
115+
' alpha.md',
116+
' beta.md',
117+
' templates/',
118+
]),
119+
'```',
120+
'',
121+
].join('\n'));
122+
}
123+
96124
function writeForkHonestAlignmentFixtures() {
97125
writeFile('.internal-research/gaps.md', [
98126
'Historical checkpoint evidence is recorded against the active checkpoint file rather than a stale missing repo path.',
@@ -323,6 +351,28 @@ describe('Health — WARN: ROADMAP references nonexistent phase', () => {
323351
const json = JSON.parse(result.output);
324352
assert.ok(!json.warnings.some((w) => w.id === 'W4'), 'should ignore future planned phases');
325353
});
354+
355+
test('active phase with only non-lifecycle artifacts → W4 without W5', async () => {
356+
await initWorkspace();
357+
fs.writeFileSync(
358+
path.join(tmpDir, '.planning', 'ROADMAP.md'),
359+
'# Roadmap\n\n- [x] **Phase 47: Synthesis And v1.7 Plan**\n'
360+
);
361+
const phaseDir = path.join(tmpDir, '.planning', 'phases', '47-synthesis-and-v1-7-plan');
362+
fs.mkdirSync(phaseDir, { recursive: true });
363+
fs.writeFileSync(
364+
path.join(phaseDir, '47-v1.7-IMPLEMENTATION-PLAN.md'),
365+
'# Next Milestone Implementation Plan\n'
366+
);
367+
368+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
369+
const json = JSON.parse(result.output);
370+
371+
assert.ok(json.warnings.some((w) => w.id === 'W4' && /Phase 47/.test(w.message)),
372+
'non-lifecycle artifacts must not make active phase health clean');
373+
assert.ok(!json.warnings.some((w) => w.id === 'W5'),
374+
'non-lifecycle artifacts must not create stale PLAN/SUMMARY warnings');
375+
});
326376
});
327377

328378
describe('Health — WARN: phase with PLAN but no SUMMARY', () => {
@@ -404,6 +454,126 @@ describe('Health — WARN: adapter and truth drift detection', () => {
404454
assert.ok(json.warnings.some((w) => w.id === 'W8'));
405455
});
406456

457+
test('current Workflow Surface inventory shape → no W8', async () => {
458+
await initWorkspace();
459+
writeAlignedTruthFixtures();
460+
writeWorkflowInventoryReadme({ heading: '## Workflow Surface' });
461+
462+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
463+
const json = JSON.parse(result.output);
464+
465+
assert.ok(!json.warnings.some((w) => w.id === 'W8'),
466+
'current Workflow Surface table and plain tree shape should align with workflows dir');
467+
});
468+
469+
test('legacy Current Status inventory shape → no W8', async () => {
470+
await initWorkspace();
471+
writeAlignedTruthFixtures();
472+
writeWorkflowInventoryReadme({ heading: '## Current Status (updated 2026-04-10)' });
473+
474+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
475+
const json = JSON.parse(result.output);
476+
477+
assert.ok(!json.warnings.some((w) => w.id === 'W8'),
478+
'legacy Current Status table should remain compatible with workflows dir');
479+
});
480+
481+
test('framework tree glyph inventory shape → no W8', async () => {
482+
await initWorkspace();
483+
writeAlignedTruthFixtures();
484+
writeWorkflowInventoryReadme({
485+
heading: '## Workflow Surface',
486+
treeLines: [
487+
'distilled/',
488+
'├── workflows/',
489+
'│ ├── alpha.md',
490+
'│ └── beta.md',
491+
'└── templates/',
492+
],
493+
});
494+
495+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
496+
const json = JSON.parse(result.output);
497+
498+
assert.ok(!json.warnings.some((w) => w.id === 'W8'),
499+
'tree glyph framework inventory should align with workflows dir');
500+
});
501+
502+
test('missing workflow table entry → W8', async () => {
503+
await initWorkspace();
504+
writeAlignedTruthFixtures();
505+
writeWorkflowInventoryReadme({ rows: ['alpha.md'] });
506+
507+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
508+
const json = JSON.parse(result.output);
509+
510+
const warning = json.warnings.find((w) => w.id === 'W8');
511+
assert.ok(warning, 'missing workflow table entry should warn');
512+
assert.match(warning.message, /missing from status table: beta\.md/);
513+
});
514+
515+
test('canonical Workflow Surface table is not masked by legacy Current Status rows', async () => {
516+
await initWorkspace();
517+
writeAlignedTruthFixtures();
518+
writeFile('distilled/README.md', [
519+
'## Workflow Surface',
520+
'',
521+
'| Workflow | What ships |',
522+
'|----------|------------|',
523+
'| `alpha.md` | x |',
524+
'',
525+
'Architecture notes:',
526+
'',
527+
'## Current Status (updated 2026-04-10)',
528+
'',
529+
'| Workflow | Status | Notes |',
530+
'|----------|--------|-------|',
531+
'| `alpha.md` | x |',
532+
'| `beta.md` | x |',
533+
'',
534+
'Architecture notes:',
535+
'',
536+
'## Files In This Framework',
537+
'',
538+
'```',
539+
'distilled/',
540+
' workflows/',
541+
' alpha.md',
542+
' beta.md',
543+
' templates/',
544+
'```',
545+
'',
546+
].join('\n'));
547+
548+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
549+
const json = JSON.parse(result.output);
550+
551+
const warning = json.warnings.find((w) => w.id === 'W8');
552+
assert.ok(warning, 'canonical Workflow Surface drift should warn even when legacy Current Status is complete');
553+
assert.match(warning.message, /missing from status table: beta\.md/);
554+
});
555+
556+
test('missing workflow tree entry → W8', async () => {
557+
await initWorkspace();
558+
writeAlignedTruthFixtures();
559+
writeWorkflowInventoryReadme({
560+
rows: ['alpha.md', 'beta.md'],
561+
treeLines: [
562+
'distilled/',
563+
' workflows/',
564+
' alpha.md',
565+
' templates/',
566+
],
567+
});
568+
569+
const result = await runCliAsMain(tmpDir, ['health', '--json']);
570+
const json = JSON.parse(result.output);
571+
572+
const warning = json.warnings.find((w) => w.id === 'W8');
573+
assert.ok(warning, 'missing workflow tree entry should warn');
574+
assert.match(warning.message, /missing from framework tree: beta\.md/);
575+
});
576+
407577
test('gaps.md stale repo-local path reference → W9', async () => {
408578
await initWorkspace();
409579
writeFile('.internal-research/gaps.md', 'Missing file: `distilled/missing.md`\n');

tests/phase.test.cjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,70 @@ describe('Phase 29 lifecycle-state helper', () => {
728728
);
729729
});
730730

731+
test('does not classify implementation-plan handoff artifacts as executable phase plans', async () => {
732+
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '47-synthesis-minimal-hardening-and-v1-7-plan'), { recursive: true });
733+
fs.writeFileSync(
734+
path.join(tmpDir, '.planning', 'ROADMAP.md'),
735+
[
736+
'# Roadmap',
737+
'',
738+
'### v1.6 Release Spine Hardening',
739+
'',
740+
'- [x] **Phase 47: Synthesis, Minimal Hardening, And v1.7 Plan** — [REL-04]',
741+
].join('\n')
742+
);
743+
fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '- [x] **[REL-04]**: v1.7 plan\n');
744+
fs.writeFileSync(
745+
path.join(tmpDir, '.planning', 'phases', '47-synthesis-minimal-hardening-and-v1-7-plan', '47-PLAN.md'),
746+
'# executable phase plan\n'
747+
);
748+
fs.writeFileSync(
749+
path.join(tmpDir, '.planning', 'phases', '47-synthesis-minimal-hardening-and-v1-7-plan', '47-SUMMARY.md'),
750+
'# phase summary\n'
751+
);
752+
fs.writeFileSync(
753+
path.join(tmpDir, '.planning', 'phases', '47-synthesis-minimal-hardening-and-v1-7-plan', '47-v1.7-IMPLEMENTATION-PLAN.md'),
754+
'# next-milestone implementation plan candidate\n'
755+
);
756+
757+
const mod = await importLifecycleStateModule();
758+
const state = mod.evaluateLifecycleState({ planningDir: path.join(tmpDir, '.planning') });
759+
760+
assert.ok(
761+
state.phaseArtifacts.some((artifact) => artifact.displayPath.endsWith('47-v1.7-IMPLEMENTATION-PLAN.md') && artifact.kind === 'other'),
762+
'implementation-plan handoff files must stay kind=other. FIX: classify only exact <baseId>-PLAN.md files as executable phase plans.'
763+
);
764+
assert.deepStrictEqual(state.incompletePlans, [],
765+
'implementation-plan handoff files must not create stale in-progress W5 warnings. FIX: keep incompletePlans limited to exact executable PLAN artifacts.');
766+
});
767+
768+
test('phase CLI ignores implementation-plan handoff artifacts when finding executable plans', async () => {
769+
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '47-synthesis-minimal-hardening-and-v1-7-plan'), { recursive: true });
770+
fs.writeFileSync(
771+
path.join(tmpDir, '.planning', 'ROADMAP.md'),
772+
[
773+
'# Roadmap',
774+
'',
775+
'### v1.6 Release Spine Hardening',
776+
'',
777+
'- [x] **Phase 47: Synthesis, Minimal Hardening, And v1.7 Plan** — [REL-04]',
778+
].join('\n')
779+
);
780+
fs.writeFileSync(
781+
path.join(tmpDir, '.planning', 'phases', '47-synthesis-minimal-hardening-and-v1-7-plan', '47-v1.7-IMPLEMENTATION-PLAN.md'),
782+
'# next-milestone implementation plan candidate\n'
783+
);
784+
785+
const result = await runCliAsMain(tmpDir, ['verify', '47']);
786+
assert.strictEqual(result.exitCode, 0, result.output);
787+
788+
const output = JSON.parse(result.output);
789+
assert.strictEqual(output.exists, false);
790+
assert.deepStrictEqual(output.plans, [],
791+
'phase CLI must not treat IMPLEMENTATION-PLAN handoff files as executable plans. FIX: keep phase.mjs classifier exact-name based.');
792+
assert.strictEqual(output.verified, false);
793+
});
794+
731795
test('derives active brownfield change continuity from CHANGE.md and HANDOFF.md without a roadmap', async () => {
732796
fs.mkdirSync(path.join(tmpDir, '.planning', 'brownfield-change'), { recursive: true });
733797
fs.writeFileSync(
@@ -1164,6 +1228,7 @@ describe('Phase 30 lifecycle-preflight helper', () => {
11641228
assert.strictEqual(output.reason, 'roadmap_phase_status_mismatch');
11651229
assert.ok(output.blockers.some((blocker) => blocker.code === 'roadmap_phase_status_mismatch'));
11661230
});
1231+
11671232
});
11681233

11691234
describe('verify command nested phase plans', () => {

0 commit comments

Comments
 (0)