fix(#3749): route migrate-config, project_exists, and health repairs through the project-aware resolver (#3955)

* test(#3749): GSD_PROJECT-scoped migrate-config, project_exists, and repair paths

* fix(#3749): route migrate-config, project_exists, and health repairs through the project-aware resolver

* chore(#3749): changeset fragment (pr number backfilled after PR creation)

* chore(#3749): backfill changeset PR number (3955)

* test(#3749): assert on the POSIX-normalized project_path across platforms

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 17:57:05 -04:00
committed by GitHub
parent fa41bfec5c
commit ddeb141bd2
9 changed files with 177 additions and 15 deletions

View File

@@ -1204,7 +1204,11 @@ function cmdConfigPath(cwd: string, _raw: boolean, workstreamContext: Workstream
*/
function cmdMigrateConfig(cwd: string, raw: boolean): void {
const ws = process.env['GSD_WORKSTREAM'] || null;
const report = migrateOnDisk(cwd, ws || undefined);
// #3749: resolve the migration target through the project-aware resolver so
// GSD_PROJECT scopes the write; migrateOnDisk itself cannot (see its
// configPathOverride note).
const scopedConfigPath = path.join(planningDir(cwd, ws || undefined), 'config.json');
const report = migrateOnDisk(cwd, ws || undefined, scopedConfigPath);
// #3760: deduplicated on (path, reason), so a repeated invocation stays quiet.
if (report.skipped.length > 0) {

View File

@@ -318,8 +318,14 @@ function mergeDefaults(parsed: Record<string, unknown>): Record<string, unknown>
return deepMergeConfig(defaults, parsed);
}
function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
const configPath = join(planningDir(cwd, workstream), 'config.json');
function migrateOnDisk(cwd: string, workstream?: string, configPathOverride?: string): MigrateOnDiskResult {
// #3749: the caller (cmdMigrateConfig in config.cts) supplies the config
// path resolved through planning-workspace's PROJECT-aware planningDir.
// This module cannot import that sibling (#3571 install-layout contract),
// and its own local planningDir above is deliberately workstream-only —
// resolving here through the local copy made migrate-config under
// GSD_PROJECT rewrite the ROOT config instead of the scoped one.
const configPath = configPathOverride ?? join(planningDir(cwd, workstream), 'config.json');
let raw: string;
try {
raw = readFileSync(configPath, 'utf-8');

View File

@@ -119,7 +119,7 @@ const CONSISTENCY_RULES: Rule[] = [
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningRoot, planningDir } = planningWorkspaceMod;
const { planningDir } = planningWorkspaceMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
const { CONFIG_DEFAULTS } = configLoaderMod;
@@ -216,7 +216,13 @@ interface RepairPaths {
* document for the read side.
*/
function repairPaths(cwd: string): RepairPaths {
const rootBase = planningRoot(cwd);
// #3749: the project root is PROJECT-aware but deliberately NOT workstream-
// scoped — under GSD_PROJECT, config.json/MILESTONES.md/milestones belong to
// `.planning/<project>/` (the same base init.new-project's config_path
// names); under GSD_WORKSTREAM they stay at the workstream PARENT, keeping
// the documented root-vs-workstream split. planningDir(cwd, null) is exactly
// that base: project env honored, ws env suppressed.
const rootBase = planningDir(cwd, null);
const wsBase = planningDir(cwd);
return {
rootBase,

View File

@@ -1313,7 +1313,7 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, un
commit_docs: config.commit_docs,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
has_codebase_map: hasCodebaseMap,
planning_exists: pathExistsInternal(cwd, '.planning'),
@@ -1386,7 +1386,7 @@ function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string,
)
: null,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
@@ -1514,7 +1514,7 @@ function cmdInitQuick(
function cmdInitIngestDocs(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const result: Record<string, unknown> = {
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
planning_exists: fs.existsSync(planningRoot(cwd)),
...getInitGitState(cwd),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. The
@@ -1565,7 +1565,7 @@ function cmdInitResume(cwd: string, raw: boolean): void {
const result: Record<string, unknown> = {
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
planning_exists: fs.existsSync(planningRoot(cwd)),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
@@ -2295,7 +2295,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
archived_milestones: archivedMilestones,
archive_count: archivedMilestones.length,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), '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')),
@@ -2711,7 +2711,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
waiting_signal: waitingSignal,
all_complete:
completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
roadmap_exists: true,
state_exists: true,
manager_flags: managerFlags,
@@ -3231,7 +3231,7 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
has_work_in_progress: !!currentPhase,
phase_mvp_mode: phaseMvpMode,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.

View File

@@ -55,7 +55,7 @@ type HealthDiagnostic = healthDiagnosticMod.Diagnostic;
import planningSnapshotMod = require('./planning-snapshot.cjs');
const { buildPlanningSnapshot } = planningSnapshotMod;
const { planningDir, planningRoot } = planningWorkspace;
const { planningDir } = planningWorkspace;
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
const { readStateHeadFreshness } = stateMod;
@@ -1604,8 +1604,10 @@ function cmdValidateHealth(
return;
}
// rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here)
const rootBase = planningRoot(cwd);
// rootBase resolves to the PROJECT-scoped planning root (`.planning[/<project>]`
// — PROJECT.md, config.json live here; #3749). Workstream-free by construction:
// planningDir(cwd, null) honors GSD_PROJECT and suppresses GSD_WORKSTREAM.
const rootBase = planningDir(cwd, null);
const _slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, _slashRuntime) as string;