Files
msd-core/tests/todos-workstream-scope.test.cjs
Tom Boucher 0a0905705a fix(#4256): resolve todos from the root via todosDir everywhere (#4479)
* test(#4256): pin todos as root-scoped under workstreams (RED)

* fix(#4256): resolve todos from the root via todosDir everywhere

* chore(#4256): changeset fragment (pr number to backfill)

* chore(#4256): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-07 08:24:14 -04:00

319 lines
15 KiB
JavaScript

'use strict';
// ─────────────────────────────────────────────────────────────────────────────
// #4256 — todos are root-scoped shared state; readers must read the root.
//
// The migrateToWorkstreams contract (src/workstream.cts) keeps todos among the
// SHARED files that "stay in place" at .planning/todos/, and every workflow
// writer writes that literal root path. But all six code readers composed
// their todos path from the workstream-aware planningDir(cwd), so under a
// workstream they read .planning/workstreams/<ws>/todos/pending/ — a directory
// nothing creates — and each reader's ENOENT guard turned "wrong directory"
// into a legitimate-looking zero: invisible todos, `todo complete` refusing
// files that exist, and `audit-open` (a milestone-close gate) printing "All
// artifact types clear" with pending todos on disk.
//
// The fix converges every reader on the root-scoped todosDir(cwd) resolver
// (src/planning-workspace.cts, beside quickDirFrom per #2142/#3149), and moves
// audit acknowledge's requireSafePath boundary with it. These rows pin the
// converged behavior under a workstream AND the byte-identical flat-mode
// control (planningDir === planningRoot with no project/workstream active).
// ─────────────────────────────────────────────────────────────────────────────
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs');
const planningWorkspace = require('../gsd-core/bin/lib/planning-workspace.cjs');
const TODO_A = '2026-09-01-one.md';
const TODO_B = '2026-09-02-two.md';
function seedRootTodos(tmpDir) {
fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'pending'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'completed'), { recursive: true });
for (const [file, title, created] of [[TODO_A, 'One', '2026-09-01'], [TODO_B, 'Two', '2026-09-02']]) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'todos', 'pending', file),
`---\ntitle: ${title}\ncreated: ${created}\narea: general\npriority: high\n---\nbody\n`,
);
}
}
function seedWorkstreamDir(tmpDir, ws) {
// Minimal workstream layout — enough for the ws discriminator to resolve a
// real directory; nothing more is needed to reproduce the scope split.
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', ws, 'phases'), { recursive: true });
}
function queryJson(args, cwd, env = {}) {
const r = runGsdTools(args, cwd, env);
assert.ok(r.success, `gsd-tools ${args.join(' ')} failed: ${r.error}`);
return JSON.parse(r.output);
}
describe('#4256: todos readers resolve the root under a workstream', () => {
test('init.todos counts root todos with GSD_WORKSTREAM set (regression)', (t) => {
const tmpDir = createTempProject('gsd-4256-initws-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.todo_count, 2, `root todos must be visible under a workstream; got ${out.todo_count}`);
assert.ok(
toPosixPath(out.pending_dir).endsWith('.planning/todos/pending'),
`pending_dir must be the ROOT pending dir, got ${out.pending_dir}`,
);
assert.equal(out.todos_dir_exists, true, 'todos_dir_exists must probe the root todos dir');
assert.equal(out.pending_dir_exists, true, 'pending_dir_exists must probe the root pending dir');
assert.ok(
Array.isArray(out.todos) && out.todos.length === 2,
'the todo list itself must carry both root files',
);
assert.ok(
toPosixPath(out.todos[0].path).includes(`.planning/todos/pending/${TODO_A}`),
`per-todo path must point at the root file, got ${out.todos[0].path}`,
);
});
test('init.todos counts root todos via explicit --ws flag', (t) => {
const tmpDir = createTempProject('gsd-4256-initflag-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['query', 'init.todos', '--ws', 'feature-a', '--raw'], tmpDir);
assert.equal(out.todo_count, 2, '--ws must not hide root todos');
assert.ok(
toPosixPath(out.pending_dir).endsWith('.planning/todos/pending'),
`pending_dir must stay root-scoped under --ws, got ${out.pending_dir}`,
);
});
test('list-todos returns root todos under a workstream', (t) => {
const tmpDir = createTempProject('gsd-4256-listws-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['query', 'list-todos'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.count, 2, `list-todos must see root todos under a workstream; got ${out.count}`);
const files = out.todos.map((x) => x.file).sort();
assert.deepEqual(files, [TODO_A, TODO_B]);
assert.ok(
toPosixPath(out.todos[0].path).startsWith('.planning/todos/pending/'),
`list-todos paths must be root-relative, got ${out.todos[0].path}`,
);
});
test('todo match-phase sees the root todo set under a workstream', (t) => {
const tmpDir = createTempProject('gsd-4256-matchws-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['query', 'todo', 'match-phase', '1', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.todo_count, 2, `match-phase must scan the root todo set; got ${out.todo_count}`);
});
test('todo complete moves the ROOT file pending -> completed under a workstream', (t) => {
const tmpDir = createTempProject('gsd-4256-complete-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['query', 'todo', 'complete', TODO_A], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.completed, true);
const rootPending = path.join(tmpDir, '.planning', 'todos', 'pending', TODO_A);
const rootCompleted = path.join(tmpDir, '.planning', 'todos', 'completed', TODO_A);
assert.ok(!fs.existsSync(rootPending), 'source must be removed from the ROOT pending dir');
assert.ok(fs.existsSync(rootCompleted), 'target must land in the ROOT completed dir');
const content = fs.readFileSync(rootCompleted, 'utf8');
assert.match(content, /^status: completed$/m, 'completion fields must be written into frontmatter');
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-a', 'todos')),
'complete must NOT materialize the divergent workstream todos dir',
);
});
test('todo complete --dry-run finds the root todo and mutates nothing (#4096 fence intact)', (t) => {
const tmpDir = createTempProject('gsd-4256-dryrun-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['query', 'todo', 'complete', TODO_A, '--dry-run'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.dry_run, true);
assert.equal(out.would_complete, true);
assert.ok(
toPosixPath(out.would_move.source).endsWith(`.planning/todos/pending/${TODO_A}`),
`dry-run source must be the ROOT pending file, got ${out.would_move.source}`,
);
assert.ok(
toPosixPath(out.would_move.target).endsWith(`.planning/todos/completed/${TODO_A}`),
`dry-run target must be the ROOT completed file, got ${out.would_move.target}`,
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', TODO_A)) &&
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', TODO_B)),
'a dry run must leave both root files untouched',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', TODO_A)),
'a dry run must not write the completed copy',
);
});
test('audit-open counts root pending todos under a workstream — the close gate FAILS', (t) => {
const tmpDir = createTempProject('gsd-4256-gate-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const out = queryJson(['audit-open', '--json'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.counts.todos, 2, `the close gate must count the root todos; got ${out.counts.todos}`);
assert.equal(out.has_open_items, true, 'pending root todos must block the milestone close');
const names = out.items.todos.map((i) => i.filename).sort();
assert.deepEqual(names, [TODO_A, TODO_B]);
});
test('audit acknowledge writes the marker into the ROOT todo under a workstream (boundary moved)', (t) => {
const tmpDir = createTempProject('gsd-4256-ack-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
const env = { GSD_WORKSTREAM: 'feature-a' };
const ack = queryJson(
['audit-open', 'acknowledge', '--category', 'todos', '--milestone', 'v1.0', '--filename', TODO_A],
tmpDir,
env,
);
assert.equal(ack.acknowledged, true);
const rootFile = path.join(tmpDir, '.planning', 'todos', 'pending', TODO_A);
assert.ok(fs.existsSync(rootFile), 'the acknowledged todo stays in the ROOT pending dir');
const content = fs.readFileSync(rootFile, 'utf8');
assert.match(content, /^audit_acknowledged:/m, 'the marker must be spliced into the ROOT file');
const after = queryJson(['audit-open', '--json'], tmpDir, env);
assert.equal(after.acknowledged.todos, 1, 'the acknowledged root todo must be tallied, not open');
assert.equal(after.counts.todos, 1, 'only the unacknowledged root todo stays open');
});
});
describe('#4256: negative space — flat, project, and boundary scopes', () => {
test('flat mode (no workstream) counts are unchanged', (t) => {
const tmpDir = createTempProject('gsd-4256-flat-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
const init = queryJson(['query', 'init.todos', '--raw'], tmpDir);
assert.equal(init.todo_count, 2);
assert.ok(toPosixPath(init.pending_dir).endsWith('.planning/todos/pending'));
const audit = queryJson(['audit-open', '--json'], tmpDir);
assert.equal(audit.counts.todos, 2);
});
test('GSD_PROJECT mode also reads the cwd-relative root todos dir', (t) => {
const tmpDir = createTempProject('gsd-4256-project-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
// Workflow writers use the literal cwd-relative `.planning/todos/...`, so
// under a project scope the files live at the planning ROOT, not under
// `.planning/<project>/todos/` — the reader must converge on the root.
const out = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_PROJECT: 'myapp' });
assert.equal(out.todo_count, 2, `root todos must be visible under GSD_PROJECT; got ${out.todo_count}`);
assert.ok(
toPosixPath(out.pending_dir).endsWith('.planning/todos/pending'),
`pending_dir must stay at the root under GSD_PROJECT, got ${out.pending_dir}`,
);
});
test('legacy per-workstream symlink workaround becomes inert — count stays correct', (t) => {
const tmpDir = createTempProject('gsd-4256-symlink-');
t.after(() => cleanup(tmpDir));
seedRootTodos(tmpDir);
seedWorkstreamDir(tmpDir, 'feature-a');
// The pre-fix workaround: a relative symlink redirecting the workstream
// path to the root. Post-fix readers never traverse the workstream
// prefix, so it must neither break nor double-count.
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'feature-a');
try {
fs.symlinkSync('../../todos', path.join(wsDir, 'todos'), 'dir');
} catch (err) {
t.skip(`filesystem does not support symlinks: ${err.code}`);
return;
}
const out = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(out.todo_count, 2, `count must stay exact with the workaround symlink present; got ${out.todo_count}`);
});
test('fresh project with no todos dir under a workstream is a legitimate zero', (t) => {
const tmpDir = createTempProject('gsd-4256-empty-');
t.after(() => cleanup(tmpDir));
seedWorkstreamDir(tmpDir, 'feature-a');
const init = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(init.todo_count, 0);
assert.equal(init.pending_dir_exists, false);
const list = queryJson(['query', 'list-todos'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(list.count, 0);
const audit = queryJson(['audit-open', '--json'], tmpDir, { GSD_WORKSTREAM: 'feature-a' });
assert.equal(audit.counts.todos, 0);
});
});
describe('#4256: todosDir resolver owns the path (planning-workspace)', () => {
const { todosDirFrom, todosDir, planningPaths, planningRoot } = planningWorkspace;
test('todosDirFrom(base) composes <base>/todos', () => {
assert.equal(todosDirFrom(path.join('/fake', 'planning')), path.join('/fake', 'planning', 'todos'));
});
test('todosDir(cwd) is root-scoped — GSD_WORKSTREAM does not move it', (t) => {
const saved = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'feature-a';
t.after(() => {
if (saved === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = saved;
});
assert.equal(todosDir('/fake/repo'), path.join('/fake', 'repo', '.planning', 'todos'));
assert.equal(todosDir('/fake/repo'), path.join(planningRoot('/fake/repo'), 'todos'));
});
test('planningPaths keeps workstream keys scoped while todos stays root-scoped', () => {
const saved = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
const paths = planningPaths('/fake/repo', 'feature-x');
assert.ok(toPosixPath(paths.planning).endsWith('.planning/workstreams/feature-x'));
assert.ok(toPosixPath(paths.state).endsWith('.planning/workstreams/feature-x/STATE.md'));
assert.ok(toPosixPath(paths.quick).endsWith('.planning/workstreams/feature-x/quick'));
assert.ok(
toPosixPath(paths.todos).endsWith('.planning/todos'),
`planningPaths().todos must be deliberately root-scoped, got ${paths.todos}`,
);
} finally {
if (saved !== undefined) process.env.GSD_WORKSTREAM = saved;
}
});
});