Files
msd-core/tests/state-todos-render.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

440 lines
20 KiB
JavaScript

'use strict';
// #2618: deterministic renderer for STATE.md's "### Pending Todos" section
// body. See .msd/phase/feat-2618-compact-todo-pointers/40-design.md and
// 50-test-matrix.md for the full rationale and case list.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const fc = require('fast-check');
const initLib = require('../msd-core/bin/lib/init.cjs');
const { renderPendingTodosMarkdown } = initLib;
const { cleanup } = require('./helpers.cjs');
const { escapeRegex } = require('../msd-core/bin/lib/pattern.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const MAX = 240;
function makeTodo(overrides) {
return Object.assign(
{
created: '2026-09-01',
area: 'api',
title: 'Fix retry logic',
path: '.planning/todos/pending/2026-09-01-fix-retry-logic.md',
},
overrides,
);
}
test('renderPendingTodosMarkdown: zero todos renders exactly "None yet."', () => {
assert.equal(renderPendingTodosMarkdown([]), 'None yet.');
});
test('renderPendingTodosMarkdown: one todo without a needs clause', () => {
const body = renderPendingTodosMarkdown([makeTodo()]);
const lines = body.split('\n');
assert.equal(lines.length, 1);
assert.match(lines[0], /^- \[2026-09-01\] \[api\] Fix retry logic — \[todo file\]\(.+\)$/);
assert.doesNotMatch(lines[0], /Needs/);
});
test('renderPendingTodosMarkdown: "TBD" solution omits the needs clause', () => {
const body = renderPendingTodosMarkdown([makeTodo({ needs: undefined })]);
assert.doesNotMatch(body, /Needs/);
});
test('renderPendingTodosMarkdown: real solution text becomes a "Needs ..." clause', () => {
const body = renderPendingTodosMarkdown([makeTodo({ needs: 'define retry behavior' })]);
assert.match(body, /Needs define retry behavior\.$/);
});
test('renderPendingTodosMarkdown: needs text already ending in "." does not double the period', () => {
const body = renderPendingTodosMarkdown([makeTodo({ needs: 'Add a max-attempts cap.' })]);
assert.match(body, /Needs Add a max-attempts cap\.$/);
assert.doesNotMatch(body, /\.\.$/);
});
test('renderPendingTodosMarkdown: many todos render one bullet per todo, in order', () => {
const todos = Array.from({ length: 5 }, (_, i) =>
makeTodo({ title: `Todo number ${i}`, path: `.planning/todos/pending/todo-${i}.md` }),
);
const body = renderPendingTodosMarkdown(todos);
const lines = body.split('\n');
assert.equal(lines.length, 5);
lines.forEach((line, i) => {
assert.match(line, new RegExp(`Todo number ${i} `));
});
});
test('renderPendingTodosMarkdown: markdown-special characters in title/area render verbatim', () => {
const body = renderPendingTodosMarkdown([
makeTodo({ title: 'Fix `parse()` for [bracketed] input', area: 'db|cache' }),
]);
assert.match(body, /Fix `parse\(\)` for \[bracketed\] input/);
assert.match(body, /\[db\|cache\]/);
});
test('renderPendingTodosMarkdown: boundary — 239 chars (limit-1) is not truncated', () => {
// Assemble a title so the full line lands at exactly 239 chars, then assert
// byte-identical (untruncated) output.
const base = makeTodo({ needs: undefined, title: 'X' });
const probe = renderPendingTodosMarkdown([base]);
const pad = 239 - probe.length;
assert.ok(pad > 0, 'test fixture sanity: base line must be shorter than 239 chars');
const title = 'X'.repeat(1 + pad);
const todo = makeTodo({ needs: undefined, title });
const line = renderPendingTodosMarkdown([todo]);
assert.equal(line.length, 239);
assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`);
});
test('renderPendingTodosMarkdown: boundary — 240 chars (limit) is not truncated', () => {
const base = makeTodo({ needs: undefined, title: 'X' });
const probe = renderPendingTodosMarkdown([base]);
const pad = 240 - probe.length;
const title = 'X'.repeat(1 + pad);
const todo = makeTodo({ needs: undefined, title });
const line = renderPendingTodosMarkdown([todo]);
assert.equal(line.length, 240);
assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`);
});
test('renderPendingTodosMarkdown: full ISO-8601 \'created\' renders a date-only bracket (#4439)', () => {
const line = renderPendingTodosMarkdown([makeTodo({ created: '2026-09-01T00:00:00.000Z', needs: undefined })]);
assert.match(line, /^- \[2026-09-01\] \[api\]/);
assert.doesNotMatch(line, /2026-09-01T/, 'full ISO timestamp must not leak into the rendered bullet');
});
test('renderPendingTodosMarkdown: \'created\' fallback (\'unknown\') is not sliced by the date-only formatter', () => {
const line = renderPendingTodosMarkdown([makeTodo({ created: undefined, needs: undefined })]);
assert.match(line, /^- \[unknown\] \[api\]/);
});
test('renderPendingTodosMarkdown: a malformed non-padded date is passed through unchanged, not mis-sliced', () => {
const line = renderPendingTodosMarkdown([makeTodo({ created: '2026-9-1', needs: undefined })]);
assert.match(line, /^- \[2026-9-1\] \[api\]/);
});
test('renderPendingTodosMarkdown: a non-4-digit year is not mis-sliced (near-miss YYYY-MM-DD shape)', () => {
const line = renderPendingTodosMarkdown([makeTodo({ created: '202-09-01T00:00:00.000Z', needs: undefined })]);
assert.match(line, /^- \[202-09-01T00:00:00\.000Z\] \[api\]/);
});
test('renderPendingTodosMarkdown: boundary — 241 chars (limit+1) truncates, needs dropped first', () => {
const base = makeTodo({ needs: 'x', title: 'X' });
const probe = renderPendingTodosMarkdown([base]);
const pad = 241 - probe.length;
const title = 'X'.repeat(1 + Math.max(pad, 0));
const todo = makeTodo({ needs: 'a real needs clause that should get dropped', title });
const line = renderPendingTodosMarkdown([todo]);
assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`);
assert.doesNotMatch(line, /Needs/, 'needs clause must be dropped before title is touched');
});
test('renderPendingTodosMarkdown: title truncated with floor + ellipsis when needs-drop is insufficient', () => {
const longTitle = 'A'.repeat(400);
const todo = makeTodo({ title: longTitle, needs: 'something' });
const line = renderPendingTodosMarkdown([todo]);
assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`);
assert.doesNotMatch(line, /Needs/);
assert.match(line, /…/);
// date/area/link remain byte-identical to the untruncated assembly.
assert.match(line, /^- \[2026-09-01\] \[api\] /);
assert.match(line, new RegExp(`\\[todo file\\]\\(${escapeRegex(todo.path)}\\)$`));
});
test('renderPendingTodosMarkdown: pathological — title floor + link alone exceeds cap is allowed to overflow', () => {
const veryLongPath = `.planning/todos/pending/${'p'.repeat(400)}.md`;
const todo = makeTodo({ title: 'A'.repeat(400), path: veryLongPath, needs: 'x' });
const line = renderPendingTodosMarkdown([todo]);
// Documented known limit: link is never sacrificed even if it blows the cap.
assert.ok(line.includes(veryLongPath), 'link must remain verbatim even when cap is exceeded');
});
test('property: rendered body always has one line per todo, each line <= 240 chars, link preserved verbatim', () => {
// Path length is bounded so the algorithm's floor assembly (date + 3-char
// area floor + 15-char title floor + link, needs always droppable) never
// exceeds 240 — i.e. the cap is always achievable without touching the
// link. This keeps the <= 240 assertion a real, provable invariant rather
// than a vacuous one; the pathological "cap not achievable" case is
// covered separately by the fixed "pathological" unit test above.
const todoArb = fc.record({
created: fc.constantFrom('2026-01-01', '2025-12-31', 'unknown', '2026-01-01T00:00:00.000Z', '2026-9-1'),
area: fc.string({ minLength: 0, maxLength: 40 }),
title: fc.string({ minLength: 0, maxLength: 500 }),
path: fc
.string({ minLength: 1, maxLength: 150 })
.map((s) => `.planning/todos/pending/${s}.md`),
needs: fc.option(fc.string({ minLength: 0, maxLength: 300 }), { nil: undefined }),
});
fc.assert(
fc.property(fc.array(todoArb, { minLength: 0, maxLength: 20 }), (todos) => {
const body = renderPendingTodosMarkdown(todos);
if (todos.length === 0) {
return body === 'None yet.';
}
const lines = body.split('\n');
if (lines.length !== todos.length) return false;
return lines.every((line, i) => {
const link = `[todo file](${todos[i].path})`;
return line.length <= 240 && line.includes(link);
});
}),
);
});
// ─── #4384 regression: the 240-char cap must be deterministic w.r.t. where the
// repo is checked out (macOS /private/var/folders/… bases blew the budget and
// dropped the "Needs" clause; Linux /tmp passed — next's own macos shard 3/3
// went red on exactly this, CI run 34038716700) ──────────────────────────────
// Long enough that the pre-fix absolute-link bullet exceeds 240 chars on EVERY
// OS (Linux /tmp included): base > ~90 chars is over the threshold since the
// fixed skeleton + relative tail + needs clause land near 150.
const LONG_BASE_SEGMENT = 'd'.repeat(110);
const TODO_RELATIVE_TAIL = path
.join('.planning', 'todos', 'pending', '2026-09-01-fix-retry-logic.md')
.split(path.sep)
.join('/');
test('renderPendingTodosMarkdown: cap is deterministic w.r.t. base-path length (needs clause survives long absolute bases)', () => {
const shortBase = path.resolve('/', 'msd-2618-short-base');
const longBase = path.join(shortBase, LONG_BASE_SEGMENT);
const todoAt = (base) =>
makeTodo({ path: path.join(base, TODO_RELATIVE_TAIL), needs: 'Add a max-attempts cap.' });
const fromShort = renderPendingTodosMarkdown([todoAt(shortBase)], shortBase);
const fromLong = renderPendingTodosMarkdown([todoAt(longBase)], longBase);
assert.equal(fromLong, fromShort, 'bullet must not depend on where the repo is checked out');
assert.match(fromShort, /Needs Add a max-attempts cap\.$/);
assert.match(fromLong, /Needs Add a max-attempts cap\.$/);
assert.ok(
fromLong.includes(`[todo file](${TODO_RELATIVE_TAIL})`),
'link must be the repo-relative tail, not the absolute path',
);
});
test('renderPendingTodosMarkdown: already-relative path is byte-stable with and without projectRoot', () => {
const todo = makeTodo({ needs: 'define retry behavior' });
const withRoot = renderPendingTodosMarkdown([todo], path.resolve('/', 'msd-2618-rel-root'));
assert.equal(withRoot, renderPendingTodosMarkdown([todo]));
});
test('renderPendingTodosMarkdown: absolute path without projectRoot keeps the legacy absolute link and drop order', () => {
const base = path.join(path.resolve('/', 'msd-2618-legacy-base'), LONG_BASE_SEGMENT);
const todo = makeTodo({ path: path.join(base, TODO_RELATIVE_TAIL), needs: 'a real needs clause' });
const line = renderPendingTodosMarkdown([todo]);
// Opt-out callers keep today's behavior: over-cap drops needs first, link
// verbatim (raw separators — the legacy renderer never posix-normalizes).
assert.doesNotMatch(line, /Needs/);
assert.ok(line.includes(`[todo file](${todo.path})`));
});
test('renderPendingTodosMarkdown: long base + long title still bounds the bullet and keeps the drop order', () => {
const base = path.join(path.resolve('/', 'msd-2618-order-base'), LONG_BASE_SEGMENT);
const todo = makeTodo({
path: path.join(base, TODO_RELATIVE_TAIL),
title: 'A'.repeat(300),
needs: 'something',
});
const line = renderPendingTodosMarkdown([todo], base);
assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`);
assert.doesNotMatch(line, /Needs/, 'needs clause must be dropped before title is touched');
assert.match(line, /…/);
assert.ok(line.includes(`[todo file](${TODO_RELATIVE_TAIL})`), 'relative link verbatim');
});
test('renderPendingTodosMarkdown: absolute path outside projectRoot renders a ../ relative link', () => {
const root = path.resolve('/', 'msd-2618-outside-root');
const outsideTodo = path.join(
path.resolve('/', 'msd-2618-outside-sibling'),
TODO_RELATIVE_TAIL,
);
const line = renderPendingTodosMarkdown([makeTodo({ path: outsideTodo })], root);
// The ../-form legitimately CONTAINS the absolute path as a substring, so a
// plain includes() negative is a false positive — assert the property
// itself: the link target is relative, never the absolute path.
const linkMatch = line.match(/\[todo file\]\(([^)]*)\)/);
assert.ok(linkMatch, 'bullet must contain a todo link');
assert.ok(!path.isAbsolute(linkMatch[1]), 'link target must be relative, not absolute');
assert.notEqual(linkMatch[1], outsideTodo, 'link target must not be the machine-variable absolute path');
assert.ok(
line.includes('[todo file](../'),
'link must be relative to the project root, not absolute',
);
});
test('renderPendingTodosMarkdown: path equal to projectRoot falls back to the raw link target', () => {
const root = path.resolve('/', 'msd-2618-eq-root');
const line = renderPendingTodosMarkdown([makeTodo({ path: root })], root);
assert.ok(line.includes(`[todo file](${root})`));
});
test('renderPendingTodosMarkdown: non-string path keeps the empty-link fallback', () => {
const line = renderPendingTodosMarkdown(
[makeTodo({ path: 42 })],
path.resolve('/', 'msd-2618-num-root'),
);
assert.match(line, /\[todo file\]\(\)$/);
});
// ─── pending_read_ok / pending_todos_markdown via the real CLI surface ─────
const { spawnSync } = require('node:child_process');
function runQueryInitTodos(cwd) {
const msdTools = path.join(__dirname, '..', 'msd-core', 'bin', 'msd-tools.cjs');
const result = spawnSync(process.execPath, [msdTools, 'query', 'init.todos'], {
cwd,
encoding: 'utf8',
timeout: PROBE_TIMEOUT_MS,
});
assert.equal(result.status, 0, `msd_run query init.todos failed: ${result.stderr}`);
return JSON.parse(result.stdout);
}
test('cmdInitTodos: pending_read_ok is true and pending_todos_markdown present for a healthy empty dir', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2618-'));
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, '.planning', 'todos', 'pending'), { recursive: true });
const json = runQueryInitTodos(dir);
assert.equal(json.pending_read_ok, true);
assert.equal(json.pending_todos_markdown, 'None yet.');
assert.equal(json.todo_count, 0);
});
test('cmdInitTodos: real todo file produces a rendered bullet via the CLI', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2618-'));
t.after(() => cleanup(dir));
const pendingDir = path.join(dir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, '2026-09-01-fix-retry-logic.md'),
[
'---',
'created: 2026-09-01T00:00:00.000Z',
'title: Fix retry logic',
'area: api',
'severity: major',
'files:',
' - src/api/client.cts:42',
'---',
'',
'## Problem',
'',
'Retries are unbounded.',
'',
'## Solution',
'',
'Add a max-attempts cap.',
'',
].join('\n'),
);
const json = runQueryInitTodos(dir);
assert.equal(json.pending_read_ok, true);
assert.match(json.pending_todos_markdown, /Fix retry logic/);
assert.match(json.pending_todos_markdown, /^- \[2026-09-01\] \[api\]/m);
// The Needs clause is asserted on the STRUCTURED field, not the rendered
// bullet: the bullet embeds the todo file's ABSOLUTE path, so its total
// length varies by runner tmpdir (macOS CI's /private/var/folders/… plus
// the test harness's msd-test-run-* wrapper pushed the full bullet past
// renderPendingTodoBullet's intended 240-char cap, whose documented first
// degradation step is to drop the Needs clause — a correct product
// behavior this test must not depend on the runner's path length for).
assert.equal(json.todo_count, 1);
assert.ok(Array.isArray(json.todos) && json.todos.length === 1, 'todos array must carry the one todo');
// (the raw field keeps the trailing period; only the rendered bullet
// strips it — renderPendingTodoBullet's own unit rows pin that.)
assert.equal(json.todos[0].needs, 'Add a max-attempts cap.');
});
test('cmdInitTodos: needs clause survives a deterministically long base path (#4384 macOS shape)', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2618-long-'));
t.after(() => cleanup(root));
// One long path segment: deterministic on every OS (no real macOS dependency).
// Pre-fix, the absolute-link bullet exceeds 240 chars even on Linux /tmp,
// reproducing next's macos shard failure (CI run 34038716700).
const dir = path.join(root, LONG_BASE_SEGMENT);
const pendingDir = path.join(dir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, '2026-09-01-fix-retry-logic.md'),
[
'---',
'created: 2026-09-01T00:00:00.000Z',
'title: Fix retry logic',
'area: api',
'---',
'',
'## Solution',
'',
'Add a max-attempts cap.',
'',
].join('\n'),
);
const json = runQueryInitTodos(dir);
assert.equal(json.pending_read_ok, true);
assert.equal(json.todo_count, 1);
assert.match(json.pending_todos_markdown, /Fix retry logic/);
assert.match(json.pending_todos_markdown, /Needs Add a max-attempts cap\.$/m);
assert.ok(
json.pending_todos_markdown.includes(`[todo file](${TODO_RELATIVE_TAIL})`),
'bullet link must be repo-relative',
);
assert.ok(
!json.pending_todos_markdown.includes(dir.split(path.sep).join('/')),
'absolute cwd must not leak into the rendered bullet',
);
});
test('cmdInitTodos: bullet order is filename-sorted regardless of write/insertion order', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2618-'));
t.after(() => cleanup(dir));
const pendingDir = path.join(dir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
// Written in reverse-alphabetical insertion order on purpose — readdirSync
// order is filesystem-dependent, not contractually stable, so the ONLY way
// to assert a deterministic bullet order is to sort explicitly. See #2618
// spec review finding on git-diff stability (must-have #3).
const todoBody = (title) =>
['---', 'created: 2026-09-01', `title: ${title}`, 'area: api', '---', ''].join('\n');
fs.writeFileSync(path.join(pendingDir, '2026-09-03-third.md'), todoBody('Third todo'));
fs.writeFileSync(path.join(pendingDir, '2026-09-01-first.md'), todoBody('First todo'));
fs.writeFileSync(path.join(pendingDir, '2026-09-02-second.md'), todoBody('Second todo'));
const json = runQueryInitTodos(dir);
const titles = json.todos.map((t2) => t2.title);
assert.deepEqual(titles, ['First todo', 'Second todo', 'Third todo']);
const lines = json.pending_todos_markdown.split('\n');
assert.ok(lines[0].includes('First todo'));
assert.ok(lines[1].includes('Second todo'));
assert.ok(lines[2].includes('Third todo'));
});
// ─── workflow parity guard (DEFECT.GENERATIVE-FIX) ─────────────────────────
function extractUpdateStateStep(workflowPath) {
const content = fs.readFileSync(workflowPath, 'utf8');
const match = content.match(/<step name="update_state">([\s\S]{0,20000}?)<\/step>/);
assert.ok(match, `update_state step not found in ${workflowPath}`);
return match[1].trim();
}
test('workflow parity: add-todo.md and check-todos.md update_state steps are byte-identical', () => {
const addTodoPath = path.join(__dirname, '..', 'msd-core', 'workflows', 'add-todo.md');
const checkTodosPath = path.join(__dirname, '..', 'msd-core', 'workflows', 'check-todos.md');
const a = extractUpdateStateStep(addTodoPath);
const b = extractUpdateStateStep(checkTodosPath);
assert.equal(a, b, 'update_state step prose must be identical across both workflows (DEFECT.GENERATIVE-FIX)');
});