fix(3785): case-insensitive depends_on resolution in phase resolver (#88)

* fix(3785): case-insensitive depends_on resolution in phase resolver

planMap, canonicalToId, and shortFormToId in phasePlanIndex used strict
Map.has() with no case normalization. A depends_on ref in mixed/lowercase
against an uppercase-suffix plan ID (e.g. '20-01-auth' → '20-01-Auth')
dropped the DAG edge, assigning the dependent plan to wave 1 instead of
wave 2. Fix: normalize all keys and lookup values to lowercase so the
three-tier resolution is case-insensitive. Adds regression test (#3785).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changeset): add PR 3798 changelog fragment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3785): detect case-fold collisions; apply lowercase-both to CJS path

- Add collision guard in both sdk/src/query/phase.ts (phasePlanIndex)
  and get-shit-done/bin/lib/phase.cjs (cmdPhasePlanIndex): when two plan
  IDs in the same phase are identical after toLowerCase(), throw/error
  immediately with a clear message naming both files instead of silently
  overwriting one in planMap and misrouting depends_on edges.
- Apply the same lowercase-both normalization (#3785) to cmdPhasePlanIndex
  in phase.cjs, which was missing from the original PR — planMap and
  canonicalToId keys are now lowercased on write; dep strings are
  lowercased before lookup.
- Add regression tests to tests/phase.test.cjs: case-insensitive
  resolution test (runs on all platforms) and collision-detection test
  (skipped on macOS/Windows where FS is case-insensitive).
- Update changeset to describe both the SDK and CJS fixes.

Identified via Codex adversarial review of PR #3798.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3785): address review — KNOWN GAP comment for CJS shortFormToId, canonical-casing tests, depends_on output normalization

- F1: Reword changeset for accuracy (plannerID drift trigger; two-tier CJS gap honest).
  Add KNOWN GAP comment in phase.cjs before Kahn's loop noting CJS lacks shortFormToId
  (tracked as follow-up parity gap, out of scope for #3785).
- F2: Add strict planA.id === '20-01-Auth' assertions in both SDK (vitest) and CJS (node --test)
  tests — a future regression that silently lowercases stored IDs would now fail the test.
- F3: Normalize depends_on output to canonical plan IDs in both SDK phase.ts and CJS phase.cjs.
  User-typed '20-01-auth' in depends_on resolves to '20-01-Auth' in output via planMap lookup.
  Add planB.depends_on === ['20-01-Auth'] assertions in both test suites.
- F5: Add seenLower guard-scope comment (full-ID collisions only; shared-prefix collisions
  handled by first-write-wins from sorted planFiles).
- F6: Add ASCII-safe toLowerCase comment at first call site in both SDK and CJS.
- F7: Add intentional-separation comment on seenLower vs planMap in both SDK and CJS.

Reviewers: gsd-code-reviewer (MN-01/NT-1) + sonnet adversarial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(3785): cover case-insensitive depends_on resolution branches

Add 4 focused test cases exercising branches introduced by #3785:
- All-uppercase depends_on ref resolving to lowercase plan ID via planMap
- External cross-phase dep preserved as-is in Pass 3 output (planMap miss)
- Mixed-case short canonical prefix resolving via canonicalToId
- Plans with undefined/empty depends_on emit empty array correctly

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:
Tom Boucher
2026-05-22 16:54:10 -04:00
committed by GitHub
parent 334a64168e
commit 8b6ffca51f
5 changed files with 391 additions and 14 deletions

View File

@@ -567,6 +567,207 @@ objective: Manual review needed
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'Phase not found', 'should report phase not found');
});
// #3785 — case-insensitive depends_on resolution
test('#3785: depends_on reference with different case resolves to correct plan', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '20-case-insensitive');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: filename uses uppercase suffix — plan ID becomes '20-01-Auth'
fs.writeFileSync(
path.join(phaseDir, '20-01-Auth-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
);
// Plan B: depends_on uses lowercase — must still resolve to Plan A
fs.writeFileSync(
path.join(phaseDir, '20-02-PLAN.md'),
`---\nwave: 2\nautonomous: true\ndepends_on:\n - 20-01-auth\n---\n<objective>Plan B.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 20', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-02')));
assert.ok(wave01 !== undefined, 'plan 20-01-Auth should appear in waves');
assert.ok(wave02 !== undefined, 'plan 20-02 should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`20-02 must be in a later wave than 20-01 (got wave01=${wave01}, wave02=${wave02}) — DAG edge dropped (case mismatch)`,
);
// Lock canonical casing: plan ID must be preserved as-is from the filename, not lowercased.
const planA = output.plans.find(p => p.id.startsWith('20-01'));
assert.strictEqual(planA.id, '20-01-Auth', 'canonical casing must be preserved in plan ID');
// depends_on output must use canonical plan ID, not the user-typed casing.
const planB = output.plans.find(p => p.id === '20-02');
assert.deepStrictEqual(planB.depends_on, ['20-01-Auth'], 'depends_on output must resolve to canonical ID casing');
// No unresolved-dep warning
const warnings = output.warnings ?? [];
assert.ok(
!warnings.some(w => /unresolved/i.test(w)),
`Unexpected unresolved-dep warning: ${JSON.stringify(warnings)}`,
);
});
// #3785 adversarial: two plan IDs that are identical when case-folded must
// fail fast with a clear error instead of silently routing edges to the wrong plan.
// This test can only run on Linux where the filesystem is case-sensitive.
// On macOS/Windows (case-insensitive FS), writing both files silently collapses
// them to one file, so the collision scenario cannot be triggered via disk.
test('#3785 adversarial: two plan IDs differing only by case produce a collision error', {
skip: process.platform !== 'linux' ? 'case-insensitive filesystem — collision test requires Linux' : false,
}, () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '21-collision');
fs.mkdirSync(phaseDir, { recursive: true });
// '21-01-auth-PLAN.md' → id '21-01-auth'
// '21-01-Auth-PLAN.md' → id '21-01-Auth'
// Both lowercase to '21-01-auth' — collision.
fs.writeFileSync(
path.join(phaseDir, '21-01-auth-PLAN.md'),
`---\nautonomous: true\ndepends_on: []\n---\n<objective>lowercase.</objective>\n`,
);
fs.writeFileSync(
path.join(phaseDir, '21-01-Auth-PLAN.md'),
`---\nautonomous: true\ndepends_on: []\n---\n<objective>uppercase.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 21', tmpDir);
// The command must exit with an error (non-success) naming the collision.
assert.ok(!result.success, 'phase-plan-index must fail when two plan IDs collide under case-folding');
assert.ok(
/collision/i.test(result.error ?? result.output ?? ''),
`Error output must mention 'collision', got: ${result.error ?? result.output}`,
);
});
// #3785 — all-uppercase depends_on value resolves to an all-lowercase plan ID
test('#3785: all-uppercase depends_on ref resolves to lowercase plan ID', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '22-uppercase-dep');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: all-lowercase plan ID
fs.writeFileSync(
path.join(phaseDir, '22-01-setup-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
);
// Plan B: depends_on uses ALL-UPPERCASE — must still route the DAG edge to Plan A
fs.writeFileSync(
path.join(phaseDir, '22-02-PLAN.md'),
`---\nwave: 2\nautonomous: true\ndepends_on:\n - 22-01-SETUP\n---\n<objective>Plan B.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 22', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-02')));
assert.ok(wave01 !== undefined, 'plan 22-01-setup should appear in waves');
assert.ok(wave02 !== undefined, 'plan 22-02 should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`22-02 must be in a later wave than 22-01 — all-uppercase dep should route correctly (got wave01=${wave01}, wave02=${wave02})`,
);
// depends_on output must use canonical plan ID (lowercase as-on-disk), not the uppercase ref
const planB = output.plans.find(p => p.id === '22-02');
assert.deepStrictEqual(planB.depends_on, ['22-01-setup'], 'depends_on output must resolve to canonical lowercase ID');
});
// #3785 — external (cross-phase) depends_on reference is kept as-is in output
// The Pass 3 mapping must return the original dep string when planMap has no entry for it.
test('#3785: external cross-phase depends_on ref is preserved as-is in output', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '23-external-dep');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: references a plan in a different phase (01-some-other-phase) — planMap won't have it
fs.writeFileSync(
path.join(phaseDir, '23-01-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on:\n - 01-01-prereq\n---\n<objective>Plan A.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 23', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const planA = output.plans.find(p => p.id.startsWith('23-01'));
assert.ok(planA !== undefined, 'plan 23-01 should appear in output');
// External dep must be preserved verbatim — not dropped, not resolved
assert.deepStrictEqual(planA.depends_on, ['01-01-prereq'], 'external cross-phase dep must be kept as-is in output');
});
// #3785 — mixed-case canonical prefix in depends_on resolves via canonicalToId lookup
// e.g. depends_on: '22-01-SETUP' where extractCanonicalPlanId gives '22-01' keyed lowercase
test('#3785: mixed-case short canonical prefix in depends_on resolves via canonicalToId', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '24-canon-case');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: descriptive filename — id becomes '24-01-auth-hardening'
fs.writeFileSync(
path.join(phaseDir, '24-01-auth-hardening-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
);
// Plan B: depends_on uses an uppercase short prefix '24-01' — canonicalToId maps '24-01' → '24-01-auth-hardening'
fs.writeFileSync(
path.join(phaseDir, '24-02-followup-PLAN.md'),
`---\nwave: 2\nautonomous: true\ndepends_on:\n - '24-01'\n---\n<objective>Plan B.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 24', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-02')));
assert.ok(wave01 !== undefined, '24-01-auth-hardening should appear in waves');
assert.ok(wave02 !== undefined, '24-02-followup should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`24-02 must be in a later wave than 24-01 via canonicalToId lookup (got wave01=${wave01}, wave02=${wave02})`,
);
// depends_on output: '24-01' is the canonical prefix, not in planMap directly, so falls back to dep as-is
const planB = output.plans.find(p => p.id === '24-02-followup');
assert.ok(planB !== undefined, '24-02-followup plan must be in output');
// The dep '24-01' is not a planMap key (full id is '24-01-auth-hardening'), so output keeps '24-01'
assert.deepStrictEqual(planB.depends_on, ['24-01'], 'short canonical prefix dep falls through to as-is in Pass 3 output');
});
// #3785 — plans with no depends_on (empty array) still emit correct output without errors
test('#3785: plans with undefined/empty depends_on emit empty array without errors', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '25-no-deps');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan with no depends_on key at all
fs.writeFileSync(
path.join(phaseDir, '25-01-PLAN.md'),
`---\nwave: 1\nautonomous: true\n---\n<objective>Plan A, no deps.</objective>\n`,
);
// Plan with explicit empty depends_on array
fs.writeFileSync(
path.join(phaseDir, '25-02-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan B, explicit empty deps.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 25', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const plan01 = output.plans.find(p => p.id === '25-01');
const plan02 = output.plans.find(p => p.id === '25-02');
assert.ok(plan01 !== undefined, 'plan 25-01 should appear in output');
assert.ok(plan02 !== undefined, 'plan 25-02 should appear in output');
assert.deepStrictEqual(plan01.depends_on, [], 'plan with no depends_on key must emit empty array');
assert.deepStrictEqual(plan02.depends_on, [], 'plan with explicit empty depends_on must emit empty array');
// Both independent plans land in the same wave
assert.deepStrictEqual(output.waves['1'], ['25-01', '25-02'], 'both no-dep plans should be in wave 1');
});
});