* fix: PROJECT.md is shared across workstreams, not workstream-scoped (#4455 follow-up)
Self-discovered regression, found while diagnosing #4456: #4455 (PR #4542,
merged as c6df4e1e46) resolved `project_path` in cmdInitCompleteMilestone
via the workstream-scoped planningDir(cwd), reasoning by analogy from
MILESTONES.md (which cmdMilestoneComplete genuinely does write
workstream-scoped, per an explicit #1911 comment) without checking an
actual PROJECT.md write call site.
PROJECT.md is documented as SHARED across workstreams, not cloned per
workstream:
- gsd-core/references/workstream-flag.md's directory diagram marks it
`# Shared`.
- new-milestone.md states it outright: "PROJECT.md is shared across
workstreams" — and explicitly SKIPS writing its `## Current Milestone`
heading under an active workstream specifically to avoid clobbering the
one shared file (#2308): "whichever workstream runs new-milestone last
would silently win the shared heading."
- cmdWorkstreamCreate (src/workstream.cts) never creates a PROJECT.md
under a new workstream directory — only STATE.md and phases/.
Under an active workstream, complete-milestone.md's safety commit was
therefore silently missing the real PROJECT.md from its --files list
(staging a path that never exists instead).
Also fixed, in the same change: withProjectRoot (src/init.cts) — a
helper every cmdInit* function calls to enrich its JSON output — read
PROJECT.md via the workstream-aware planningDir(cwd) to extract
project_title. This predates #4455 entirely (unrelated diff, no prior
test either direction) but is the identical defect class, one line,
directly adjacent to what this fix already touches: under an active
workstream, every init.* command's project_title field silently
vanished, since no PROJECT.md ever exists at the workstream path.
Deliberately NOT fixed here: cmdInitNewMilestone (src/init.cts, the
function backing new-milestone.md's own init.new-milestone call) has
the identical bug for project_path/project_exists/config_path
(config.json is ALSO marked `# Shared` in the same diagram). That
function is exactly what #4456 (new-milestone.md's own missing --ws
forwarding) already needs to modify — its field-by-field scoping
belongs in that follow-up, not here.
Also deliberately NOT touched: getLatestCompletedMilestone reads
MILESTONES.md via the ROOT-ONLY planningRoot(cwd), contradicting
cmdMilestoneComplete's workstream-scoped WRITE. Resolving that
disagreement requires a genuine product-intent call (is "latest
completed milestone" scoped to the current workstream or pooled
project-wide?) that isn't derivable from the code alone — left alone
rather than guessed.
Verified: direct CLI invocation confirms project_path/project_title
now resolve to the root PROJECT.md under GSD_WORKSTREAM=alpha instead
of a workstream-scoped path that no writer ever populates. New
regression tests cover both the real cmdInitCompleteMilestone function
(tests/init-manager.test.cjs) and withProjectRoot's project_title
(same file); the existing fence-level test in
tests/workstream-scoped-paths.test.cjs (which enshrined the wrong
behavior) is corrected to reflect reality.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: extend PROJECT.md root-scoping to 6 more cmdInit* functions found via grep
A code-review pass on this fix's first draft (which only touched
cmdInitCompleteMilestone and withProjectRoot) flagged that cmdInitNewProject
had the identical bug and asked whether other call sites were missed.
Grepping the exact literal patterns found SEVEN total occurrences across
cmdInitNewProject, cmdInitNewMilestone, cmdInitIngestDocs, cmdInitResume,
cmdInitMilestoneOp, cmdInitManager, and cmdInitProgress — not just the one
the review happened to spot.
All seven are the exact same defect with the exact same one-line fix
(resolve via planningRoot(cwd) instead of the workstream-scoped
planningDir(cwd)), so all seven are fixed here via a uniform find/replace,
including cmdInitNewMilestone — an earlier plan was to leave that one for
#4456 (which already needs to touch that function to add missing --ws
forwarding), but leaving exactly one of seven identical, equally-evidenced
occurrences unfixed for no functional reason would have been an arbitrary
inconsistency, not a principled scope boundary. #4456 still needs to add
the actual --ws parameter threading to cmdInitNewMilestone and separately
fix its also-wrong config_path field (config.json is marked `# Shared` in
workstream-flag.md's diagram too — a different field, needing its own
verification, not swept up in this mechanical grep-and-replace).
Also fixed: buildInitCompletenessFields (used by cmdInitNewProject and
cmdInitResume for the `init_incomplete` partial-bootstrap discriminator)
had the same bug in a differently-shaped literal (bare
fs.existsSync(path.join(dir, 'PROJECT.md')), not the pathExistsInternal/
toPosixPath wrapper the grep matched) — only its PROJECT.md check moves to
planningRoot(cwd); the REQUIREMENTS.md/MILESTONES.md/ROADMAP.md/STATE.md
checks in the same function correctly stay workstream-scoped.
Verified: direct CLI invocation of `init ingest-docs`, `init resume`,
`init progress`, `init new-project`, and `init milestone-op` under
GSD_WORKSTREAM=alpha all now report the root PROJECT.md correctly. New
parametrized regression test in tests/init-manager.test.cjs exercises all
five through the real CLI router (catching a router-wiring regression too,
not just a src/init.cts internals check).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: PROJECT.md respects GSD_PROJECT namespacing, just not GSD_WORKSTREAM
gsd-test caught a real regression in this fix's own previous push:
resolving PROJECT.md via planningRoot(cwd) ignores BOTH GSD_PROJECT and
GSD_WORKSTREAM, but only the workstream dimension is actually meant to be
ignored for PROJECT.md. tests/init.test.cjs's pre-existing #3749 coverage
("init.new-project — GSD_PROJECT scoping") establishes that PROJECT.md
legitimately lives at `.planning/<project>/PROJECT.md` when GSD_PROJECT is
set — a genuine, tested, pre-existing multi-project namespace, distinct
from a single project's own workstreams (which DO share one PROJECT.md,
per the evidence in the prior two commits).
Corrected every PROJECT.md path resolution in this diff to
planningDir(cwd, null): `ws` explicitly nulled (so GSD_WORKSTREAM is never
consulted), `project` left as undefined so it still defaults from
GSD_PROJECT. This is the correct middle ground between the original bug
(fully workstream-scoped, #4455's mistake) and the previous commit's
overcorrection (fully root-only, breaking #3749).
Verified empirically, all three combinations: GSD_WORKSTREAM alone
resolves to root; GSD_PROJECT alone resolves to the namespaced path; both
set together resolves to the namespaced path (workstream ignored, matching
the resolution-priority contract in workstream-flag.md — project owns a
distinct planning tree, workstreams exist inside ONE project's tree). New
regression tests cover both the GSD_PROJECT-alone and
GSD_PROJECT+GSD_WORKSTREAM-together cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: backfill changeset PR number
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/nimble-geese-zip.md
Normal file
5
.changeset/nimble-geese-zip.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4543
|
||||
---
|
||||
**`/gsd-complete-milestone`'s safety commit and every `/gsd-init`-family command now correctly treat PROJECT.md as a file shared across workstreams, not a per-workstream file** — a #4455 follow-up regression (and one pre-existing, adjacent bug) resolved PROJECT.md through the workstream-scoped path instead of the documented shared root path, so under an active workstream the safety commit silently missed the real PROJECT.md and every init command's `project_title` field silently disappeared.
|
||||
105
src/init.cts
105
src/init.cts
@@ -344,7 +344,15 @@ function withProjectRoot(cwd: string, result: Record<string, unknown>): Record<s
|
||||
if (config.project_code) {
|
||||
result['project_code'] = config.project_code;
|
||||
}
|
||||
const projectMdPath = path.join(planningDir(cwd), 'PROJECT.md');
|
||||
// #4455 follow-up (self-discovered): PROJECT.md is shared across a
|
||||
// project's own workstreams (never cloned per workstream) but DOES
|
||||
// respect the separate GSD_PROJECT multi-project namespace (#3749) — see
|
||||
// cmdInitCompleteMilestone's projectPath comment for the full evidence.
|
||||
// `ws` explicitly nulled, `project` left to default from GSD_PROJECT.
|
||||
// Reading via the workstream-aware planningDir(cwd) meant every init.*
|
||||
// call's project_title silently vanished whenever a workstream was
|
||||
// active, since no PROJECT.md ever exists at the workstream path.
|
||||
const projectMdPath = path.join(planningDir(cwd, null), 'PROJECT.md');
|
||||
const content = platformReadSync(projectMdPath);
|
||||
if (content) {
|
||||
const h1Match = content.match(/^#\s+(.+)$/m);
|
||||
@@ -1318,8 +1326,14 @@ function buildInitCompletenessFields(cwd: string): Record<string, boolean> {
|
||||
const planningExists = fs.existsSync(dir);
|
||||
const requirementsExists = fs.existsSync(path.join(dir, 'REQUIREMENTS.md'));
|
||||
const milestonesExists = fs.existsSync(path.join(dir, 'MILESTONES.md'));
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams (see cmdInitCompleteMilestone's projectPath comment for the
|
||||
// full evidence) — checked at planningRoot(cwd), never the workstream-scoped
|
||||
// `dir`, so a workstream whose own REQUIREMENTS/ROADMAP/STATE are all
|
||||
// present isn't wrongly reported incomplete just because the shared
|
||||
// PROJECT.md isn't ALSO duplicated under its own directory.
|
||||
const coreComplete =
|
||||
fs.existsSync(path.join(dir, 'PROJECT.md')) &&
|
||||
fs.existsSync(path.join(planningDir(cwd, null), 'PROJECT.md')) &&
|
||||
requirementsExists &&
|
||||
fs.existsSync(path.join(dir, 'ROADMAP.md')) &&
|
||||
fs.existsSync(path.join(dir, 'STATE.md'));
|
||||
@@ -1363,7 +1377,10 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, un
|
||||
// one key stay byte-identical for existing consumers.
|
||||
...buildInitCompletenessFields(cwd),
|
||||
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
has_codebase_map: hasCodebaseMap,
|
||||
planning_exists: pathExistsInternal(cwd, '.planning'),
|
||||
|
||||
@@ -1379,7 +1396,8 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, un
|
||||
exa_search_available: hasExaSearch,
|
||||
|
||||
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
|
||||
// #4455 follow-up: PROJECT.md is shared across workstreams.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')),
|
||||
// #2376: new-project.md's research-synthesizer/roadmapper spawn prompts
|
||||
// read these instead of hardcoding '.planning/...' literals.
|
||||
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
|
||||
@@ -1434,11 +1452,15 @@ function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string,
|
||||
)
|
||||
: null,
|
||||
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
|
||||
|
||||
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
|
||||
// #4455 follow-up: PROJECT.md is shared across workstreams.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')),
|
||||
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
|
||||
// #2376: new-milestone.md's research-synthesizer/roadmapper spawn prompts
|
||||
@@ -1616,7 +1638,10 @@ function cmdInitQuickBatch(
|
||||
function cmdInitIngestDocs(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const result: Record<string, unknown> = {
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
planning_exists: fs.existsSync(planningRoot(cwd)),
|
||||
...getInitGitState(cwd),
|
||||
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. The
|
||||
@@ -1625,7 +1650,8 @@ function cmdInitIngestDocs(cwd: string, raw: boolean): void {
|
||||
// hardcoded bare '.planning/intel/...', '.planning/PROJECT.md', etc.
|
||||
// literals into their Agent(prompt=...) blocks; those now interpolate
|
||||
// these fields instead.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
|
||||
// #4455 follow-up: PROJECT.md is shared across workstreams.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')),
|
||||
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
|
||||
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
|
||||
@@ -1672,13 +1698,17 @@ function cmdInitResume(cwd: string, raw: boolean): void {
|
||||
|
||||
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
|
||||
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
planning_exists: fs.existsSync(planningRoot(cwd)),
|
||||
|
||||
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
|
||||
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
|
||||
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
|
||||
// #4455 follow-up: PROJECT.md is shared across workstreams.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')),
|
||||
|
||||
has_interrupted_agent: !!interruptedAgentId,
|
||||
interrupted_agent_id: interruptedAgentId,
|
||||
@@ -2594,7 +2624,10 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
|
||||
archived_milestones: archivedMilestones,
|
||||
archive_count: archivedMilestones.length,
|
||||
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
|
||||
archive_exists: fs.existsSync(path.join(planningRoot(cwd), 'archive')),
|
||||
@@ -3039,7 +3072,10 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
waiting_signal: waitingSignal,
|
||||
all_complete:
|
||||
completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0,
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
roadmap_exists: true,
|
||||
state_exists: true,
|
||||
manager_flags: managerFlags,
|
||||
@@ -3086,16 +3122,35 @@ function cmdInitCompleteMilestone(
|
||||
const statePath = path.join(planningBase, 'STATE.md');
|
||||
const roadmapPath = path.join(planningBase, 'ROADMAP.md');
|
||||
const archiveDir = path.join(planningBase, 'milestones');
|
||||
// #4455 follow-up (code-review finding): MILESTONES.md and PROJECT.md are
|
||||
// workstream-scoped too — cmdMilestoneComplete (src/milestone.cts) writes
|
||||
// MILESTONES.md via planningPaths(cwd).planning (the workstream base, not
|
||||
// root), and planningPaths().project resolves PROJECT.md the same way.
|
||||
// Neither is the deliberately-root-scoped exception `todos` is (#4256) —
|
||||
// an earlier version of this fix wrongly treated both as shared root
|
||||
// files, which would have made the safety commit below silently miss the
|
||||
// actual files milestone.complete just wrote under an active workstream.
|
||||
// #4455 follow-up (code-review finding): MILESTONES.md is workstream-scoped
|
||||
// too — cmdMilestoneComplete (src/milestone.cts) writes it via
|
||||
// planningPaths(cwd).planning (the workstream base, not root; #1911). It is
|
||||
// not the deliberately-root-scoped exception `todos` is (#4256) — an
|
||||
// earlier version of this fix wrongly treated it as a shared root file,
|
||||
// which would have made the safety commit below silently miss the actual
|
||||
// file milestone.complete just wrote under an active workstream.
|
||||
const milestonesPath = path.join(planningBase, 'MILESTONES.md');
|
||||
const projectPath = path.join(planningBase, 'PROJECT.md');
|
||||
// #4455 follow-up round 2 (self-discovered regression): PROJECT.md, unlike
|
||||
// MILESTONES.md, is genuinely SHARED across a project's own workstreams —
|
||||
// never cloned per workstream. gsd-core/references/workstream-flag.md's
|
||||
// directory diagram marks it `# Shared`; new-milestone.md states it
|
||||
// outright ("PROJECT.md is shared across workstreams") and explicitly
|
||||
// SKIPS writing its `## Current Milestone` heading under an active
|
||||
// workstream specifically to avoid clobbering the one shared file (#2308);
|
||||
// cmdWorkstreamCreate (src/workstream.cts) never creates a PROJECT.md
|
||||
// under a workstream directory. The first version of this #4455 follow-up
|
||||
// wrongly generalized from planningPaths()'s structural shape (which
|
||||
// composes `project` under the workstream base) without checking an
|
||||
// actual PROJECT.md write path — resolved against planningRoot(cwd)
|
||||
// (round 2), but that ALSO ignores the separate GSD_PROJECT dimension
|
||||
// (multi-project namespacing, #3749: PROJECT.md legitimately lives at
|
||||
// `.planning/<project>/PROJECT.md` when GSD_PROJECT is set — a real,
|
||||
// tested, pre-existing feature planningRoot's blanket root-only read
|
||||
// broke), caught by gsd-test on this fix's own first push. `planningDir`
|
||||
// with `ws` explicitly nulled (never read from GSD_WORKSTREAM) but
|
||||
// `project` left to default from GSD_PROJECT is the correct middle
|
||||
// ground: respects project-namespacing, ignores workstream-namespacing.
|
||||
const projectPath = path.join(planningDir(cwd, null), 'PROJECT.md');
|
||||
// REQUIREMENTS.md is workstream-scoped the same way (planningPaths(cwd).requirements,
|
||||
// src/planning-workspace.cts) — the git-rm-after-archive step needs the
|
||||
// resolved path too, not the literal root file.
|
||||
@@ -3595,7 +3650,10 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
||||
has_work_in_progress: !!currentPhase,
|
||||
phase_mvp_mode: phaseMvpMode,
|
||||
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
|
||||
// #4455 follow-up (code-review finding): PROJECT.md is shared across
|
||||
// workstreams — see cmdInitCompleteMilestone's projectPath comment for
|
||||
// the full evidence.
|
||||
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))),
|
||||
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
|
||||
// #4040: partial-init discriminator (see buildInitCompletenessFields) —
|
||||
@@ -3605,7 +3663,8 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
||||
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
|
||||
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
|
||||
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
|
||||
// #4455 follow-up: PROJECT.md is shared across workstreams.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')),
|
||||
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
|
||||
};
|
||||
|
||||
|
||||
@@ -1031,6 +1031,207 @@ describe('init complete-milestone — state_path/roadmap_path/archive_dir (#4455
|
||||
});
|
||||
});
|
||||
|
||||
describe('init complete-milestone — milestones_path/project_path/requirements_path (#4455 follow-up)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = require('fs').realpathSync(createTempProject());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function writeRootProjectMd(dir) {
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'PROJECT.md'), '# Test Project\n');
|
||||
}
|
||||
|
||||
test('flat mode: milestones_path/requirements_path resolve to root, project_path resolves to root (regression guard)', () => {
|
||||
writeState(tmpDir);
|
||||
writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]);
|
||||
writeRootProjectMd(tmpDir);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), '# Milestones\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
||||
|
||||
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.milestones_path, absPlanningPath(tmpDir, 'MILESTONES.md'));
|
||||
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
||||
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
||||
});
|
||||
|
||||
test('GSD_WORKSTREAM=alpha: milestones_path/requirements_path resolve into the workstream, but project_path STAYS root (PROJECT.md is shared, #4455 follow-up regression)', () => {
|
||||
seedWorkstream(tmpDir, {
|
||||
name: 'alpha',
|
||||
state: '---\nstatus: active\n---\n# State\n',
|
||||
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
||||
});
|
||||
// PROJECT.md is only ever written at root — cmdWorkstreamCreate never
|
||||
// creates a per-workstream copy (it is a documented shared file, see
|
||||
// gsd-core/references/workstream-flag.md's directory diagram).
|
||||
writeRootProjectMd(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'MILESTONES.md'), '# Milestones\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'REQUIREMENTS.md'), '# Requirements\n');
|
||||
|
||||
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.milestones_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'MILESTONES.md'));
|
||||
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'REQUIREMENTS.md'));
|
||||
// The regression this test guards against: project_path must be the
|
||||
// ROOT PROJECT.md, never a workstream-scoped path that no writer ever
|
||||
// populates.
|
||||
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
||||
assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'PROJECT.md'));
|
||||
});
|
||||
|
||||
test('GSD_PROJECT=second-product: project_path resolves into the PROJECT namespace, not root (#3749 regression — round 1 of this fix broke this)', () => {
|
||||
// The FIRST version of this #4455 follow-up resolved project_path via
|
||||
// planningRoot(cwd), which ignores GSD_PROJECT entirely — gsd-test caught
|
||||
// this immediately (tests/init.test.cjs's pre-existing #3749 coverage) on
|
||||
// this fix's own first push. PROJECT.md is shared across a project's own
|
||||
// WORKSTREAMS, but a DIFFERENT project (GSD_PROJECT) legitimately gets
|
||||
// its own separate PROJECT.md at `.planning/<project>/PROJECT.md`. The
|
||||
// correct resolution is planningDir(cwd, null) — `ws` explicitly nulled
|
||||
// (never read from GSD_WORKSTREAM), `project` left to default from
|
||||
// GSD_PROJECT.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n');
|
||||
writeRootProjectMd(tmpDir); // an unrelated root PROJECT.md must not win
|
||||
|
||||
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_PROJECT: 'second-product' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'second-product', 'PROJECT.md'));
|
||||
assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
||||
});
|
||||
|
||||
test('GSD_PROJECT=second-product AND GSD_WORKSTREAM=alpha together: project_path follows the PROJECT namespace, ignoring the workstream', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product', 'workstreams', 'alpha'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n');
|
||||
|
||||
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_PROJECT: 'second-product', GSD_WORKSTREAM: 'alpha' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'second-product', 'PROJECT.md'));
|
||||
assert.notStrictEqual(output.project_path,
|
||||
absPlanningPath(tmpDir, 'second-product', 'workstreams', 'alpha', 'PROJECT.md'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('withProjectRoot — project_title reads PROJECT.md from root even under a workstream (#4455 follow-up)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = require('fs').realpathSync(createTempProject());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Exercised via `init complete-milestone` rather than `init manager`:
|
||||
// cmdInitManager has its own readiness guard requiring STATE.md/ROADMAP.md
|
||||
// to already exist (see the "state_path/roadmap_path are null" test
|
||||
// above), which would obscure whether THIS test is actually exercising
|
||||
// withProjectRoot. Both commands share the same withProjectRoot helper.
|
||||
test('flat mode: project_title is populated from the root PROJECT.md (regression guard)', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# My Test Project\n\nSome content.\n');
|
||||
|
||||
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.project_title, 'My Test Project');
|
||||
});
|
||||
|
||||
test('GSD_WORKSTREAM=alpha: project_title is STILL populated from the root PROJECT.md, not silently dropped (#4455 follow-up regression)', () => {
|
||||
seedWorkstream(tmpDir, {
|
||||
name: 'alpha',
|
||||
state: '---\nstatus: active\n---\n# State\n',
|
||||
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
||||
});
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# My Test Project\n\nSome content.\n');
|
||||
|
||||
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.project_title, 'My Test Project',
|
||||
'project_title must be read from the shared root PROJECT.md even when a workstream is active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init subcommands sharing the project_exists/project_path PROJECT.md pattern (#4455 follow-up)', () => {
|
||||
// A code-review pass on this fix's first draft found the identical
|
||||
// workstream-scoped-PROJECT.md bug (fixed above for cmdInitCompleteMilestone
|
||||
// and withProjectRoot) repeated verbatim, via grep, in six more cmdInit*
|
||||
// functions. Each is exercised here through its real CLI subcommand rather
|
||||
// than re-asserting src/init.cts internals directly, so a regression in the
|
||||
// router wiring would also be caught. `init manager` and `init new-milestone`
|
||||
// are deliberately excluded: `manager` has its own STATE.md/ROADMAP.md
|
||||
// readiness guard (covered separately above via `init complete-milestone`,
|
||||
// which shares withProjectRoot); `new-milestone`'s project_path fix is
|
||||
// real (see src/init.cts) but its dedicated coverage belongs with #4456's
|
||||
// own new-milestone.md workstream-forwarding work, not duplicated here.
|
||||
const SUBCOMMANDS = ['ingest-docs', 'resume', 'progress', 'new-project'];
|
||||
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = require('fs').realpathSync(createTempProject());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const subcommand of SUBCOMMANDS) {
|
||||
test(`GSD_WORKSTREAM=alpha: "init ${subcommand}" resolves project_path to the shared root PROJECT.md`, () => {
|
||||
seedWorkstream(tmpDir, {
|
||||
name: 'alpha',
|
||||
state: '---\nstatus: active\n---\n# State\n',
|
||||
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
||||
});
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Test Project\n');
|
||||
|
||||
const result = runGsdTools(`init ${subcommand}`, tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.ok(result.success, `init ${subcommand} failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.project_exists, true,
|
||||
`init ${subcommand}: project_exists must be true (root PROJECT.md exists), got: ${JSON.stringify(output.project_exists)}`);
|
||||
if ('project_path' in output) {
|
||||
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'),
|
||||
`init ${subcommand}: project_path must resolve to the shared root PROJECT.md, got: ${output.project_path}`);
|
||||
assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'PROJECT.md'),
|
||||
`init ${subcommand}: project_path must not resolve into the workstream directory`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('milestone-op: GSD_WORKSTREAM=alpha does not report project_exists=false for a root-only PROJECT.md', () => {
|
||||
// milestone-op does not expose a project_path field, only project_exists
|
||||
// via buildInitCompletenessFields — the coreComplete/init_incomplete fix.
|
||||
seedWorkstream(tmpDir, {
|
||||
name: 'alpha',
|
||||
state: '---\nstatus: active\n---\n# State\n',
|
||||
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
||||
});
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Test Project\n');
|
||||
|
||||
const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.ok(result.success, `init milestone-op failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.project_exists, true,
|
||||
`init milestone-op: project_exists must be true for a root-only PROJECT.md under a workstream, got: ${JSON.stringify(output.project_exists)}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3584-runtime-slash-emitters.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
|
||||
@@ -324,37 +324,42 @@ describe('complete-milestone.md workstream-scoped paths (#4455)', () => {
|
||||
assert.ok(out.includes(projectPath), `expected root PROJECT.md in --files, got: ${out}`);
|
||||
});
|
||||
|
||||
test('GSD_WORKSTREAM=alpha: --files lists workstream-scoped STATE/ROADMAP/archive/MILESTONES/PROJECT paths, not root (#4455 regression)', () => {
|
||||
test('GSD_WORKSTREAM=alpha: --files lists workstream-scoped STATE/ROADMAP/archive/MILESTONES paths, but PROJECT.md STAYS root (#4455 regression)', () => {
|
||||
const wsStatePath = path.join(tmpDir, 'STATE-alpha.md');
|
||||
const wsRoadmapPath = path.join(tmpDir, 'ROADMAP-alpha.md');
|
||||
const wsArchiveDir = path.join(tmpDir, 'milestones-alpha');
|
||||
const wsMilestonesPath = path.join(tmpDir, 'MILESTONES-alpha.md');
|
||||
const wsProjectPath = path.join(tmpDir, 'PROJECT-alpha.md');
|
||||
// PROJECT.md is genuinely SHARED across workstreams — never cloned per
|
||||
// workstream (gsd-core/references/workstream-flag.md marks it
|
||||
// `# Shared`; new-milestone.md states it outright; cmdWorkstreamCreate
|
||||
// never creates one under a workstream dir). The real
|
||||
// cmdInitCompleteMilestone therefore ALWAYS returns the root path for
|
||||
// project_path regardless of GSD_WORKSTREAM — reflected here by
|
||||
// passing the SAME root-shaped value the flat-mode test above uses,
|
||||
// not a workstream-shaped one. An earlier version of this fix wrongly
|
||||
// pinned MILESTONES.md and PROJECT.md as identically workstream-scoped
|
||||
// (caught by isolated code review, then re-caught as a genuine
|
||||
// regression after merge — see tests/init-manager.test.cjs's
|
||||
// "milestones_path/project_path/requirements_path" describe block for
|
||||
// the test that exercises the real init.cts function, not just this
|
||||
// fence-mechanics stub).
|
||||
const rootProjectPath = path.join(tmpDir, 'PROJECT-root.md');
|
||||
const out = runCommitFence({
|
||||
state_path: wsStatePath, roadmap_path: wsRoadmapPath, archive_dir: wsArchiveDir,
|
||||
milestones_path: wsMilestonesPath, project_path: wsProjectPath,
|
||||
milestones_path: wsMilestonesPath, project_path: rootProjectPath,
|
||||
});
|
||||
|
||||
assert.ok(out.includes(wsStatePath), `expected workstream STATE.md in --files, got: ${out}`);
|
||||
assert.ok(out.includes(wsRoadmapPath), `expected workstream ROADMAP.md in --files, got: ${out}`);
|
||||
assert.ok(out.includes(`${wsArchiveDir}/v[X.Y]-ROADMAP.md`), `expected workstream archive ROADMAP in --files, got: ${out}`);
|
||||
// MILESTONES.md and PROJECT.md are workstream-scoped too — cmdMilestoneComplete
|
||||
// (src/milestone.cts) writes MILESTONES.md via planningPaths(cwd).planning (the
|
||||
// workstream base), and PROJECT.md resolves the same way (planningPaths().project).
|
||||
// An earlier version of this fix wrongly pinned both as shared root files, which
|
||||
// would have made this safety commit silently miss the actual files
|
||||
// `milestone complete` just wrote under an active workstream (#4455 follow-up,
|
||||
// caught by isolated code review).
|
||||
assert.ok(out.includes(wsMilestonesPath), `expected workstream MILESTONES.md in --files, got: ${out}`);
|
||||
assert.ok(out.includes(wsProjectPath), `expected workstream PROJECT.md in --files, got: ${out}`);
|
||||
assert.ok(out.includes(rootProjectPath), `expected root PROJECT.md in --files, got: ${out}`);
|
||||
assert.ok(!out.includes(path.join(tmpDir, '.planning', 'STATE.md')),
|
||||
`must not fall back to the flat root STATE.md path, got: ${out}`);
|
||||
assert.ok(!out.includes(path.join(tmpDir, '.planning', 'ROADMAP.md')),
|
||||
`must not fall back to the flat root ROADMAP.md path, got: ${out}`);
|
||||
assert.ok(!out.includes(path.join(tmpDir, '.planning', 'MILESTONES.md')),
|
||||
`must not fall back to the flat root MILESTONES.md path, got: ${out}`);
|
||||
assert.ok(!out.includes(path.join(tmpDir, '.planning', 'PROJECT.md')),
|
||||
`must not fall back to the flat root PROJECT.md path, got: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user