fix(#1472,#1454): workstream-aware health paths; exclude active worktree from W017 (#1483)
* fix(#1472,#1454): validate health workstream-aware paths; exclude active worktree from W017 #1472: cmdValidateHealth now uses planningRoot(cwd) for shared-root files (PROJECT.md, config.json, MILESTONES.md) and planningDir(cwd) for workstream-scoped files (ROADMAP.md, STATE.md, phases/). Previously a single planningDir() call was used for all paths, causing false E002/E003/E004/W003 when GSD_WORKSTREAM is set. #1454: W017 no longer fires for a stale worktree whose path equals or is an ancestor of process.cwd(), preventing advice to remove the active session's own worktree. Regression tests added for both bugs; all 40 existing health tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: correct changeset format Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
10
.changeset/297bb145.md
Normal file
10
.changeset/297bb145.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1483
|
||||
---
|
||||
|
||||
fix(#1472): validate health is now workstream-aware — PROJECT.md and config.json are resolved from .planning/ root, while ROADMAP.md, STATE.md, and phases/ follow the workstream-scoped path; previously both sets were routed through planningDir() causing false E002/E003/E004/W003 when GSD_WORKSTREAM is set.
|
||||
|
||||
fix(#1454): validate health W017 no longer suggests removing the active session's worktree — stale-worktree findings are now skipped when the worktree path matches or is an ancestor of process.cwd().
|
||||
|
||||
<!-- docs-exempt: internal verify.cts fix — no public user-facing API or CLI change; behavior correction for workstream-scoped health validation -->
|
||||
@@ -48,7 +48,7 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
|
||||
import worktreeSafetyMod = require('./worktree-safety.cjs');
|
||||
const { inspectWorktreeHealth } = worktreeSafetyMod;
|
||||
|
||||
const { planningDir } = planningWorkspace;
|
||||
const { planningDir, planningRoot } = planningWorkspace;
|
||||
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
|
||||
const { writeStateMd } = stateMod;
|
||||
const { MODEL_PROFILES } = modelProfilesMod;
|
||||
@@ -1235,12 +1235,18 @@ function cmdValidateHealth(
|
||||
return;
|
||||
}
|
||||
|
||||
const planBase = planningDir(cwd);
|
||||
const projectPath = path.join(planBase, 'PROJECT.md');
|
||||
const roadmapPath = path.join(planBase, 'ROADMAP.md');
|
||||
const statePath = path.join(planBase, 'STATE.md');
|
||||
const configPath = path.join(planBase, 'config.json');
|
||||
const phasesDir = path.join(planBase, 'phases');
|
||||
// rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here)
|
||||
// wsBase resolves to .planning/workstreams/<ws>/ when GSD_WORKSTREAM is set (STATE.md, ROADMAP.md, phases/)
|
||||
const rootBase = planningRoot(cwd);
|
||||
const wsBase = planningDir(cwd);
|
||||
// planBase is kept as an alias for wsBase for all the internal helpers (collectDiskPhases, etc.)
|
||||
// that are already parameterised on the workstream-aware path.
|
||||
const planBase = wsBase;
|
||||
const projectPath = path.join(rootBase, 'PROJECT.md');
|
||||
const roadmapPath = path.join(wsBase, 'ROADMAP.md');
|
||||
const statePath = path.join(wsBase, 'STATE.md');
|
||||
const configPath = path.join(rootBase, 'config.json');
|
||||
const phasesDir = path.join(wsBase, 'phases');
|
||||
const _slashRuntime = resolveRuntime(cwd);
|
||||
const slash = (name: string) => formatGsdSlash(name, _slashRuntime) as string;
|
||||
|
||||
@@ -1262,7 +1268,7 @@ function cmdValidateHealth(
|
||||
else info.push(issue);
|
||||
};
|
||||
|
||||
if (!fs.existsSync(planBase)) {
|
||||
if (!fs.existsSync(rootBase)) {
|
||||
addIssue('error', 'E001', '.planning/ directory not found', `Run ${slash('new-project')} to initialize`);
|
||||
output({ status: 'broken', errors, warnings, info, repairable_count: 0 }, raw);
|
||||
return;
|
||||
@@ -1683,11 +1689,21 @@ function cmdValidateHealth(
|
||||
}
|
||||
|
||||
if (finding['kind'] === 'stale') {
|
||||
// Do not flag the active session's worktree — removing it would be harmful.
|
||||
const worktreePath = finding['path'] as string;
|
||||
const activeCwd = process.cwd();
|
||||
const normalizedWorktree = path.resolve(worktreePath);
|
||||
const normalizedCwd = path.resolve(activeCwd);
|
||||
// Skip if the worktree IS the cwd or is an ancestor of it.
|
||||
const isActiveWorktree =
|
||||
normalizedCwd === normalizedWorktree ||
|
||||
normalizedCwd.startsWith(normalizedWorktree + path.sep);
|
||||
if (isActiveWorktree) continue;
|
||||
addIssue(
|
||||
'warning',
|
||||
'W017',
|
||||
`Stale git worktree: ${finding['path'] as string} (last modified ${finding['ageMinutes'] as number} minutes ago)`,
|
||||
`Run: git worktree remove ${finding['path'] as string} --force`,
|
||||
`Stale git worktree: ${worktreePath} (last modified ${finding['ageMinutes'] as number} minutes ago)`,
|
||||
`Run: git worktree remove ${worktreePath} --force`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1727,8 +1743,8 @@ function cmdValidateHealth(
|
||||
/* W021 check is advisory — skip on error */
|
||||
}
|
||||
|
||||
const milestonesPath = path.join(planBase, 'MILESTONES.md');
|
||||
const milestonesArchiveDir = path.join(planBase, 'milestones');
|
||||
const milestonesPath = path.join(rootBase, 'MILESTONES.md');
|
||||
const milestonesArchiveDir = path.join(rootBase, 'milestones');
|
||||
const missingFromRegistry: string[] = [];
|
||||
try {
|
||||
if (fs.existsSync(milestonesArchiveDir)) {
|
||||
@@ -1764,7 +1780,7 @@ function cmdValidateHealth(
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(planBase, { withFileTypes: true });
|
||||
const entries = fs.readdirSync(rootBase, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
@@ -1868,7 +1884,7 @@ function cmdValidateHealth(
|
||||
}
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const projectRef = path
|
||||
.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md'))
|
||||
.relative(cwd, path.join(rootBase, 'PROJECT.md'))
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
let stateContent = `# Session State\n\n`;
|
||||
|
||||
@@ -1028,3 +1028,135 @@ describe('validate health — missing phasesDir', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #1472 regression — workstream-aware paths
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validate health — #1472 workstream-aware path resolution', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('reports healthy when GSD_WORKSTREAM is set and files are in the correct workstream layout', () => {
|
||||
// Shared-root files at .planning/
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Project\n\n## What This Is\n\nTest project.\n\n## Core Value\n\nCore value here.\n\n## Requirements\n\nRequirements here.\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced', commit_docs: true, workflow: { nyquist_validation: true, ai_integration_phase: true } }, null, 2)
|
||||
);
|
||||
|
||||
// Workstream-scoped files at .planning/workstreams/ws-a/
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-a');
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n### Phase 1: Setup\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'STATE.md'),
|
||||
'# Session State\n\n## Current Position\n\nPhase: 1\n'
|
||||
);
|
||||
const wsPhaseDir = path.join(wsDir, 'phases', '01-setup');
|
||||
fs.mkdirSync(wsPhaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir, { GSD_WORKSTREAM: 'ws-a' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
// PROJECT.md and config.json must NOT be reported missing (E002/W003)
|
||||
assert.ok(
|
||||
!output.errors.some(e => e.code === 'E002'),
|
||||
`E002 (PROJECT.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
assert.ok(
|
||||
!output.errors.some(e => e.code === 'E003'),
|
||||
`E003 (ROADMAP.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
assert.ok(
|
||||
!output.errors.some(e => e.code === 'E004'),
|
||||
`E004 (STATE.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W003'),
|
||||
`W003 (config.json missing) should not fire with workstream layout: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
// Status should not be 'broken' due to path misrouting
|
||||
assert.notStrictEqual(
|
||||
output.status, 'broken',
|
||||
`Status should not be broken when files exist in the correct workstream layout: ${JSON.stringify(output)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('without GSD_WORKSTREAM, shared-root files are found at .planning/ root', () => {
|
||||
// All files at .planning/ root (no workstream sub-path)
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.errors.some(e => ['E002', 'E003', 'E004'].includes(e.code)),
|
||||
`No E002/E003/E004 should fire in standard non-workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #1454 regression — W017 must not fire for the active worktree
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validate health — #1454 W017 excludes active worktree', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// The active-worktree exclusion guard in the SUT (src/verify.cts) compares
|
||||
// process.cwd() against each stale-finding path at runtime. The integration
|
||||
// scenario below covers the observable CLI contract; the unit-level injection
|
||||
// path is omitted here because bin/lib/verify.cjs is a gitignored tsc artifact
|
||||
// not present in a fresh worktree.
|
||||
|
||||
test('validate health completes without W017 for the cwd itself when inspected as worktree root', () => {
|
||||
// Set up a minimal healthy project at tmpDir
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
// Run with cwd = tmpDir. The SUT will call process.cwd() which is the test runner's cwd,
|
||||
// not tmpDir, so any stale worktree that matches tmpDir (as a non-cwd) CAN legitimately
|
||||
// be flagged. The guard only protects the CURRENT process.cwd().
|
||||
// What we assert: when there is no real git repo at tmpDir, no W017 fires (git worktree
|
||||
// list will fail / return empty — the try/catch swallows it silently).
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W017'),
|
||||
`W017 should not fire for a non-git project dir: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user