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

@@ -610,3 +610,58 @@ test('mergeDefaults clones defaults without JSON serialization fragility (#321)'
});
});
}
// ─── #3749: migrate-config must be project-aware under GSD_PROJECT ──────────
describe('migrate-config — GSD_PROJECT scoping (#3749)', () => {
test('migrates the SCOPED config.json, never the root one', (t) => {
const tmpDir = createTempProject('gsd-3749-migrate-');
t.after(() => cleanup(tmpDir));
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true });
// Root carries the legacy key; the scoped config is canonical.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ depth: 'quick' }),
);
const scopedPath = path.join(tmpDir, '.planning', 'second-product', 'config.json');
fs.writeFileSync(scopedPath, JSON.stringify({ granularity: 'standard' }));
// Scoped config has no legacy keys → the honest outcome is a no-op that
// writes NOTHING (issue #3749 expectation: "reports that the scoped config
// has no legacy keys and writes nothing"). Either way the root file must
// not be touched.
const r = runMigrateConfig(tmpDir, [], { GSD_PROJECT: 'second-product' });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
const root = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'));
assert.equal(root['depth'], 'quick', '#3749: the root project\'s config must be untouched');
assert.equal(root['granularity'], undefined, '#3749: no migration may be written into the root config');
});
test('a scoped config WITH legacy keys is the migration target', (t) => {
const tmpDir = createTempProject('gsd-3749-migrate2-');
t.after(() => cleanup(tmpDir));
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ granularity: 'fine' }),
);
const scopedPath = path.join(tmpDir, '.planning', 'second-product', 'config.json');
fs.writeFileSync(scopedPath, JSON.stringify({ depth: 'quick' }));
const r = runMigrateConfig(tmpDir, [], { GSD_PROJECT: 'second-product' });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
const parsed = JSON.parse(r.stdout);
const scoped = JSON.parse(fs.readFileSync(scopedPath, 'utf8'));
assert.equal(scoped['granularity'], 'coarse', '#3749: the SCOPED config must be migrated');
assert.equal(scoped['depth'], undefined, 'legacy key removed from the scoped config');
const root = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'));
assert.equal(root['granularity'], 'fine', '#3749: root config must remain byte-equivalent');
if (parsed['wrote']) {
assert.ok(
String(parsed['wrote']).includes(path.join('.planning', 'second-product')),
`#3749: wrote must name the scoped file, got ${parsed['wrote']}`,
);
}
});
});

View File

@@ -559,3 +559,52 @@ describe('applyRepairs — REAL diagnostics (rows 15-16)', () => {
assert.ok(detail.error, 'the details row must carry the thrown error message');
});
});
// ─── #3749: repair paths must be project-aware under GSD_PROJECT ────────────
describe('health repair — GSD_PROJECT scoping (#3749)', () => {
test('createConfig writes config.json beside the SCOPED planning dir, not the root', (t) => {
const tmpDir = createTempDir('gsd-3749-repair-');
t.after(() => cleanup(tmpDir));
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n');
const diagnostics = [
fakeDiagnostic('W003', REMEDY_ACTION.CREATE_CONFIG, REMEDY_RISK.NONE),
];
const prevProject = process.env['GSD_PROJECT'];
process.env['GSD_PROJECT'] = 'second-product';
t.after(() => {
if (prevProject === undefined) delete process.env['GSD_PROJECT'];
else process.env['GSD_PROJECT'] = prevProject;
});
const result = applyRepairs(tmpDir, diagnostics, true, false);
t.after(() => { delete process.env['GSD_PROJECT']; if (prevProject !== undefined) process.env['GSD_PROJECT'] = prevProject; });
assert.deepEqual(result.applied, ['W003']);
const scopedConfig = path.join(tmpDir, '.planning', 'second-product', 'config.json');
assert.ok(fs.existsSync(scopedConfig), '#3749: config.json must be created inside the scoped project');
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'config.json')),
'#3749: the repair must not create a root config.json under GSD_PROJECT');
});
test('under GSD_WORKSTREAM (no project) config stays OUT of the workstream dir', (t) => {
const tmpDir = createTempDir('gsd-3749-repair-ws-');
t.after(() => cleanup(tmpDir));
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true });
const diagnostics = [
fakeDiagnostic('W003', REMEDY_ACTION.CREATE_CONFIG, REMEDY_RISK.NONE),
];
const prev = process.env['GSD_WORKSTREAM'];
process.env['GSD_WORKSTREAM'] = 'alpha';
const result = applyRepairs(tmpDir, diagnostics, true, false);
if (prev === undefined) delete process.env['GSD_WORKSTREAM'];
else process.env['GSD_WORKSTREAM'] = prev;
assert.deepEqual(result.applied, ['W003']);
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'config.json')),
'the documented root-vs-workstream split keeps config.json at the workstream PARENT');
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'config.json')),
'config.json must not move into the workstream dir');
});
});

View File

@@ -4707,3 +4707,38 @@ describe('#3581: init.progress next_phase prefers the roadmap frontier', () => {
assert.equal(out.next_phase, null, 'all-complete milestone: no frontier (completion flow owns the answer)');
});
});
// ─── #3749: project_exists must follow project_path under GSD_PROJECT ───────
describe('init.new-project — GSD_PROJECT scoping (#3749)', () => {
test('project_exists tracks the namespaced PROJECT.md, not the root one', (t) => {
const tmpDir = createTempProject('gsd-3749-init-');
t.after(() => cleanup(tmpDir));
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n');
const r1 = runGsdTools(['query', 'init.new-project'], tmpDir, { GSD_PROJECT: 'second-product' });
assert.ok(r1.success, r1.error);
const out1 = JSON.parse(r1.output);
assert.equal(out1['project_exists'], true,
`#3749: project_path (${out1['project_path']}) names an existing file — project_exists must be true`);
// project_path is POSIX-normalized by toPosixPath — compare with a literal
// forward-slash path, not path.join (which yields backslashes on Windows).
assert.ok(String(out1['project_path']).includes('.planning/second-product'));
// An unrelated root PROJECT.md must not change the verdict.
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# unrelated\n');
const r2 = runGsdTools(['query', 'init.new-project'], tmpDir, { GSD_PROJECT: 'second-product' });
assert.ok(r2.success, r2.error);
assert.equal(JSON.parse(r2.output)['project_exists'], true,
'#3749: verdict must not flip when an unrelated root file appears');
});
test('without GSD_PROJECT the root PROJECT.md still answers project_exists', (t) => {
const tmpDir = createTempProject('gsd-3749-init2-');
t.after(() => cleanup(tmpDir));
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Root Project\n');
const r = runGsdTools(['query', 'init.new-project'], tmpDir);
assert.ok(r.success, r.error);
assert.equal(JSON.parse(r.output)['project_exists'], true, 'default (unscoped) behavior unchanged');
});
});