Files
msd-core/tests/core-utils.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

1475 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests for src/core-utils.cts (compiled to msd-core/bin/lib/core-utils.cjs).
*
* Verifies behavioural contracts of the utilities extracted from core.cjs
* per ADR-857 rollout phase 2c (#877):
* - toPosixPath
* - detectSubRepos
* - extractOneLinerFromBody
* - pathExistsInternal
* - generateSlugInternal
* - getPhaseFileStats
* - readSubdirectories
* - timeAgo
* - extractCanonicalPlanId (private — only via coreUtils, NOT via core)
* - core.cjs re-export shims resolve to the exact same functions (shim-identity)
*
* Adversarial inputs per QA matrix: path-traversal-like names, unicode,
* decimal phase ids, missing/empty dirs, fs edge cases.
* Uses helpers.cjs createTempProject/cleanup for filesystem tests.
*/
'use strict';
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const coreUtils = require('../msd-core/bin/lib/core-utils.cjs');
const { SCOPE } = require('../msd-core/bin/lib/planning-scope.cjs');
const {
cleanup, runMsdTools, scrubConfigLocationEnv,
saveSessionEnv, restoreSessionEnv, clearSessionEnv, TEST_HOME_SANDBOX_MARKER,
captureFdSync,
} = require('./helpers.cjs');
const phaseLocator = require('../msd-core/bin/lib/phase-locator.cjs');
const phaseId = require('../msd-core/bin/lib/phase-id.cjs');
const workstreamNamePolicy = require('../msd-core/bin/lib/workstream-name-policy.cjs');
const activeWorkstreamStore = require('../msd-core/bin/lib/active-workstream-store.cjs');
const gsd2Import = require('../msd-core/bin/lib/gsd2-import.cjs');
const commandsMod = require('../msd-core/bin/lib/commands.cjs');
const initMod = require('../msd-core/bin/lib/init.cjs');
// #3883/Stryker shard budget (scripts/mutation-matrix.cjs `core-utils` entry,
// `tests/state-contract.test.cjs`'s header documents the same mechanism):
// Stryker's command-runner bills one `node --test <file>` invocation as a
// single unit costing whatever the file's slowest run costs, re-run once per
// mutant. A3/A5 originally drove every CLI-reachable slug site through
// `runMsdTools` (a real child-process spawn per call, ~85-170ms each across
// ~70 calls) — ~7.5s of the file's ~7.9s wall time, which blew the 15-minute
// shard cap. `cmdGenerateSlug` / `cmdInitExecutePhase` / `cmdInitPhaseOp` /
// `cmdInitProgress` are plain functions reachable in-process from the built
// `msd-core/bin/lib/*.cjs` — calling them directly removes the spawn
// entirely instead of moving the cost to a sibling file. `captureFd1Sync`
// intercepts the fd-level write these commands make via io.cjs's
// `writeAllSync` → `fs.writeSync(1, ...)` (bug #1008's pattern, already
// established in tests/io.test.cjs and tests/init.test.cjs's
// `captureInitVerifyWork` — NOT `process.stdout.write`, which silently
// captures nothing here). `withHermeticInProcessEnv` reproduces the isolation
// `runMsdTools(..., { HOME: tmpDir })` + `testEnvBase()` gave the child
// process (HOME/USERPROFILE sandbox + config-location env scrub + session-
// identity env clear) so an in-process call can't read the developer's real
// `~/.msd` config or leak real session-identity env into the slug output.
function captureFd1Sync(fn) {
return captureFdSync(1, fn);
}
function withHermeticInProcessEnv(dir, fn) {
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedMarker = process.env[TEST_HOME_SANDBOX_MARKER];
const savedSession = saveSessionEnv();
const restoreConfigEnv = scrubConfigLocationEnv();
clearSessionEnv();
process.env.HOME = dir;
process.env.USERPROFILE = dir;
process.env[TEST_HOME_SANDBOX_MARKER] = dir;
try {
return fn();
} finally {
restoreConfigEnv();
restoreSessionEnv(savedSession);
if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile;
if (savedMarker === undefined) delete process.env[TEST_HOME_SANDBOX_MARKER];
else process.env[TEST_HOME_SANDBOX_MARKER] = savedMarker;
}
}
function cmdGenerateSlugInProcess(text) {
const captured = captureFd1Sync(() => commandsMod.cmdGenerateSlug(text, false));
return JSON.parse(captured).slug;
}
// ─── toPosixPath ─────────────────────────────────────────────────────────────
describe('toPosixPath', () => {
test('forward-slash paths are unchanged', () => {
assert.strictEqual(coreUtils.toPosixPath('foo/bar/baz'), 'foo/bar/baz');
});
test('empty string returns empty string', () => {
assert.strictEqual(coreUtils.toPosixPath(''), '');
});
test('single segment (no separators) is unchanged', () => {
assert.strictEqual(coreUtils.toPosixPath('file.txt'), 'file.txt');
});
test('platform path.sep is normalized to /', () => {
// On POSIX this is a no-op; on Windows it converts backslashes.
const sep = path.sep;
const p = ['a', 'b', 'c'].join(sep);
assert.strictEqual(coreUtils.toPosixPath(p), 'a/b/c');
});
test('adversarial: path-traversal-like string with backslash separators', () => {
// On POSIX, path.sep === '/' so backslashes are treated as literal characters
// and toPosixPath leaves them as-is (split on '/' only finds one token).
// On Windows (where path.sep === '\\'), backslashes would be normalized to '/'.
// Either way, the result is a string and does not throw.
const result = coreUtils.toPosixPath('..\\..\\etc\\passwd');
assert.strictEqual(typeof result, 'string');
if (path.sep === '\\') {
// Windows: separators normalized
assert.ok(result.includes('/'));
assert.ok(!result.includes('\\'));
} else {
// POSIX: backslash is a literal char, not a separator
assert.ok(result.includes('\\'));
}
});
test('unicode in path segments passes through', () => {
const result = coreUtils.toPosixPath('中文/path/to/file');
assert.strictEqual(result, '中文/path/to/file');
});
});
// ─── detectSubRepos ───────────────────────────────────────────────────────────
describe('detectSubRepos', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns empty array for directory with no sub-repos', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []);
});
test('returns empty array for non-existent directory', () => {
assert.deepEqual(coreUtils.detectSubRepos('/nonexistent-path-xyz-' + Date.now()), []);
});
test('detects directory with .git as sub-repo', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const subDir = path.join(tmpDir, 'myrepo');
fs.mkdirSync(subDir);
fs.mkdirSync(path.join(subDir, '.git'));
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), ['myrepo']);
});
// #4458: a linked git worktree's .git is a FILE (a `gitdir: <path>` pointer),
// not a directory. detectSubRepos uses fs.existsSync (type-agnostic), so this
// was already correct before #4458 — this test proves it explicitly, since
// the actual #4458 defect was new-project.md's own `find -exec test -d
// "{}/.git"` predicate never calling this helper at all.
test('detects directory with .git as a FILE (linked worktree) as sub-repo', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const subDir = path.join(tmpDir, 'myworktree');
fs.mkdirSync(subDir);
fs.writeFileSync(path.join(subDir, '.git'), 'gitdir: /some/main/repo/.git/worktrees/myworktree\n');
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), ['myworktree']);
});
test('excludes hidden directories', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const hiddenDir = path.join(tmpDir, '.hidden');
fs.mkdirSync(hiddenDir);
fs.mkdirSync(path.join(hiddenDir, '.git'));
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []);
});
test('excludes node_modules', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const nmDir = path.join(tmpDir, 'node_modules');
fs.mkdirSync(nmDir);
fs.mkdirSync(path.join(nmDir, '.git'));
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []);
});
test('returns sorted results for multiple sub-repos', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
for (const name of ['z-repo', 'a-repo', 'm-repo']) {
const subDir = path.join(tmpDir, name);
fs.mkdirSync(subDir);
fs.mkdirSync(path.join(subDir, '.git'));
}
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), ['a-repo', 'm-repo', 'z-repo']);
});
test('adversarial: directory name with path-traversal-like characters', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
// Create a subdirectory that doesn't start with '.' and isn't node_modules
const subDir = path.join(tmpDir, 'normal-dir');
fs.mkdirSync(subDir);
// No .git, so not a sub-repo
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []);
});
});
// ─── pathExistsInternal ───────────────────────────────────────────────────────
describe('pathExistsInternal', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns true for an existing file', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const fp = path.join(tmpDir, 'file.txt');
fs.writeFileSync(fp, 'hello');
assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'file.txt'), true);
});
test('returns true for an existing directory', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const subDir = path.join(tmpDir, 'subdir');
fs.mkdirSync(subDir);
assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'subdir'), true);
});
test('returns false for a non-existent path', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'nope.txt'), false);
});
test('handles absolute targetPath', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, tmpDir), true);
});
test('adversarial: path traversal attempt returns false (no such file)', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
// Traversal resolves via path.join — no crash, just correct false/true
const result = coreUtils.pathExistsInternal(tmpDir, '../nonexistent');
assert.strictEqual(typeof result, 'boolean');
});
});
// ─── generateSlugInternal ─────────────────────────────────────────────────────
describe('generateSlugInternal', () => {
test('null → null', () => {
assert.strictEqual(coreUtils.generateSlugInternal(null), null);
});
test('undefined → null', () => {
assert.strictEqual(coreUtils.generateSlugInternal(undefined), null);
});
test('empty string → null', () => {
assert.strictEqual(coreUtils.generateSlugInternal(''), null);
});
test('lowercases and replaces non-alphanumeric with hyphens', () => {
assert.strictEqual(coreUtils.generateSlugInternal('Hello World!'), 'hello-world');
});
test('strips leading and trailing hyphens', () => {
assert.strictEqual(coreUtils.generateSlugInternal(' Hello '), 'hello');
});
test('truncates at 60 characters', () => {
const long = 'a'.repeat(100);
const result = coreUtils.generateSlugInternal(long);
assert.ok(result !== null && result.length <= 60);
});
// ─── #2849: trailing hyphen must not survive 60-char truncation. ───────────
// The strip ran before .substring(0, 60), so a cut landing on a separator
// produced a slug ending in `-`. The strip must run after truncation.
test('#2849 — trailing hyphen is stripped after 60-char truncation', () => {
// 59 a's + space + "tail" → "aaaa…aaa-tail" (64 chars). Truncating at 60
// lands on the separator → "aaaa…aaa-" (ends in `-`) without the fix.
const slug = coreUtils.generateSlugInternal('a'.repeat(59) + ' tail');
assert.ok(slug !== null, 'slug must not be null');
assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`);
assert.ok(slug.length <= 60, `slug must be at most 60 chars; got length ${slug?.length}`);
// The tail word is truncated away — the slug is the 59 a's with no separator.
assert.strictEqual(slug, 'a'.repeat(59));
});
test('#2849 — truncation landing before a separator keeps a clean boundary', () => {
// 58 a's + space + "b" = 60 chars exactly. Truncation keeps all 60 → "aaa…aa-b".
const slug = coreUtils.generateSlugInternal('a'.repeat(58) + ' b');
assert.ok(slug !== null);
assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`);
assert.strictEqual(slug?.length, 60);
assert.strictEqual(slug, 'a'.repeat(58) + '-b');
});
test('#2849 — leading hyphens are still stripped after the truncation reorder', () => {
// Leading punctuation becomes a hyphen, then is stripped. Truncation runs
// after the strip; the leading-hyphen guarantee must survive the reorder.
const slug = coreUtils.generateSlugInternal('!!!' + 'a'.repeat(60));
assert.ok(slug !== null);
assert.ok(!slug.startsWith('-'), `slug must not start with a hyphen; got: ${JSON.stringify(slug)}`);
assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`);
assert.ok((slug?.length ?? 0) <= 60);
});
test('#2849 — long Cyrillic transliterates and truncates without a trailing hyphen', () => {
// Transliteration expands Cyrillic; the result can exceed 60 chars and land
// on a separator when truncated. The post-truncation strip must still fire.
const slug = coreUtils.generateSlugInternal('Объект день '.repeat(10).trim());
assert.ok(slug !== null);
assert.ok(!slug.includes('Объект'), 'non-ASCII must be transliterated away');
assert.ok(/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug), `slug must be ASCII-only and well-formed; got: ${slug}`);
assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`);
assert.ok((slug?.length ?? 0) <= 60);
});
test('#2849 — all-separator input collapses to empty, not a stray hyphen', () => {
// Input that is entirely separators must reduce to '' (not null, not '-'),
// both short and when truncated past 60 chars.
assert.strictEqual(coreUtils.generateSlugInternal('!!!'), '');
assert.strictEqual(coreUtils.generateSlugInternal('!'.repeat(70)), '');
});
test('unicode characters are replaced with hyphens', () => {
const result = coreUtils.generateSlugInternal('中文phase');
assert.ok(typeof result === 'string');
assert.ok(!result.includes('中'));
});
test('preserves numbers in slug', () => {
assert.strictEqual(coreUtils.generateSlugInternal('Phase 42 Done'), 'phase-42-done');
});
// ─── #2858 — wait, #2848: non-Latin (Cyrillic) titles must not produce an
// empty slug. A transliteration map is applied before the ASCII filter so the
// title's meaning is preserved as ASCII. Latin-script output is byte-for-byte
// unchanged (negative control below).
test('#2848 row 1 — Cyrillic title produces a non-empty transliterated slug', () => {
// Russian "Проверка гипотезы" → "proverka gipotezy" → slug.
const result = coreUtils.generateSlugInternal('Проверка гипотезы');
assert.ok(typeof result === 'string' && result.length > 0, `Cyrillic title must not produce an empty slug; got: ${JSON.stringify(result)}`);
assert.ok(/^[a-z0-9]+(-[a-z0-9]+)*$/.test(result), `slug must be ASCII-only and well-formed; got: ${result}`);
assert.strictEqual(result, 'proverka-gipotezy');
});
test('#2848 row 3 — Latin-script output is byte-for-byte unchanged (negative control)', () => {
// These must remain identical to the pre-fix outputs.
assert.strictEqual(coreUtils.generateSlugInternal('Hello World!'), 'hello-world');
assert.strictEqual(coreUtils.generateSlugInternal('Setup environment'), 'setup-environment');
assert.strictEqual(coreUtils.generateSlugInternal(' Hello '), 'hello');
assert.strictEqual(coreUtils.generateSlugInternal('Phase 42 Done'), 'phase-42-done');
});
test('#2848 row 4 — multi-letter Cyrillic mappings transliterate correctly', () => {
// ж→zh ч→ch ш→sh щ→sch ю→yu я→ya. 'Яша Щучин' → ya-sh-a + sch-u-ch-i-n.
const result = coreUtils.generateSlugInternal('Яша Щучин');
assert.ok(result, `expected non-empty slug; got: ${result}`);
assert.ok(result.includes('yasha'), `я→ya + ш→sh + а→a = yasha expected; got: ${result}`);
assert.ok(result.includes('schuchin'), `щ→sch + у→u + ч→ch expected; got: ${result}`);
assert.strictEqual(result, 'yasha-schuchin');
// Spot-check each multi-letter mapping in isolation.
assert.strictEqual(coreUtils.generateSlugInternal('ж'), 'zh');
assert.strictEqual(coreUtils.generateSlugInternal('ч'), 'ch');
assert.strictEqual(coreUtils.generateSlugInternal('ш'), 'sh');
assert.strictEqual(coreUtils.generateSlugInternal('щ'), 'sch');
assert.strictEqual(coreUtils.generateSlugInternal('ю'), 'yu');
assert.strictEqual(coreUtils.generateSlugInternal('я'), 'ya');
});
test('#2848 row 5 — soft/hard signs (ъ ь) drop cleanly without hyphen runs', () => {
// "Объект день" — ъ and ь should disappear, NOT produce consecutive hyphens.
const result = coreUtils.generateSlugInternal('Объект день');
assert.ok(result, `expected non-empty slug; got: ${result}`);
assert.ok(!result.includes('--'), `no double hyphens from dropped signs; got: ${result}`);
assert.strictEqual(result, 'obekt-den');
});
test('#2848 row 6 — Ukrainian/Belarusian Cyrillic extras transliterate', () => {
// і ї є ґ ў — non-Russian Cyrillic letters in the reported scope.
assert.strictEqual(coreUtils.generateSlugInternal('і'), 'i');
assert.strictEqual(coreUtils.generateSlugInternal('ї'), 'yi');
assert.strictEqual(coreUtils.generateSlugInternal('є'), 'ye');
assert.strictEqual(coreUtils.generateSlugInternal('ґ'), 'g');
assert.strictEqual(coreUtils.generateSlugInternal('ў'), 'u');
});
test('#2848 row 7 — null/undefined/empty still return null (contract preserved)', () => {
assert.strictEqual(coreUtils.generateSlugInternal(null), null);
assert.strictEqual(coreUtils.generateSlugInternal(undefined), null);
assert.strictEqual(coreUtils.generateSlugInternal(''), null);
});
test('#2848 row 9 — mixed Latin+Cyrillic title transliterates correctly', () => {
assert.strictEqual(coreUtils.generateSlugInternal('Phase Фаза 42'), 'phase-faza-42');
});
test('#2848 row 10 — truncation still applies after transliteration (≤60 chars)', () => {
// A long Cyrillic title transliterates to a longer ASCII string; the 60-char
// cap must still bind the result.
const long = 'Проверка'.repeat(20);
const result = coreUtils.generateSlugInternal(long);
assert.ok(result !== null && result.length <= 60, `truncation must still apply; got len ${result && result.length}`);
});
});
// ─── readSubdirectories ───────────────────────────────────────────────────────
describe('readSubdirectories', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns [] for non-existent directory', () => {
assert.deepEqual(coreUtils.readSubdirectories('/nonexistent-xyz-' + Date.now()), []);
});
test('returns [] for empty directory', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
assert.deepEqual(coreUtils.readSubdirectories(tmpDir), []);
});
test('returns only directory names, not files', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.mkdirSync(path.join(tmpDir, 'subdir'));
fs.writeFileSync(path.join(tmpDir, 'file.txt'), '');
const result = coreUtils.readSubdirectories(tmpDir);
assert.deepEqual(result, ['subdir']);
});
test('sort=false returns dirs in filesystem order', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.mkdirSync(path.join(tmpDir, '02-phase'));
fs.mkdirSync(path.join(tmpDir, '01-phase'));
const result = coreUtils.readSubdirectories(tmpDir, false);
assert.strictEqual(result.length, 2);
});
test('sort=true orders by comparePhaseNum', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
for (const name of ['10-phase', '02-phase', '01-phase']) {
fs.mkdirSync(path.join(tmpDir, name));
}
const result = coreUtils.readSubdirectories(tmpDir, true);
assert.deepEqual(result, ['01-phase', '02-phase', '10-phase']);
});
test('sort=true handles decimal phase ids correctly', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
for (const name of ['01.2-phase', '01.10-phase', '01.1-phase']) {
fs.mkdirSync(path.join(tmpDir, name));
}
const result = coreUtils.readSubdirectories(tmpDir, true);
// Decimal ordering: 01.1 < 01.2 < 01.10
assert.deepEqual(result, ['01.1-phase', '01.2-phase', '01.10-phase']);
});
});
// ─── getPhaseFileStats ────────────────────────────────────────────────────────
describe('getPhaseFileStats', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns empty arrays and false flags for empty directory', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.deepEqual(stats.plans, []);
assert.deepEqual(stats.summaries, []);
assert.strictEqual(stats.hasResearch, false);
assert.strictEqual(stats.hasContext, false);
assert.strictEqual(stats.hasVerification, false);
assert.strictEqual(stats.hasReviews, false);
});
test('detects PLAN.md files', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'PLAN.md'), '');
fs.writeFileSync(path.join(tmpDir, '01-PLAN.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.deepEqual(stats.plans.sort(), ['01-PLAN.md', 'PLAN.md']);
});
test('detects SUMMARY.md files', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'SUMMARY.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.deepEqual(stats.summaries, ['SUMMARY.md']);
});
test('detects RESEARCH.md', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'RESEARCH.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.hasResearch, true);
});
test('detects *-RESEARCH.md', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'feature-RESEARCH.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.hasResearch, true);
});
test('detects VERIFICATION.md', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'VERIFICATION.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.hasVerification, true);
});
test('detects REVIEWS.md', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'REVIEWS.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.hasReviews, true);
});
test('detects CONTEXT.md via findContextMdIn', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'CONTEXT.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.hasContext, true);
});
// ─── #3183 (ADR-3180 Decision 2): scope field + degrade-not-throw ─────────
test('#3183 row 17: scope is COMPLETE for a readable, empty phase dir', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.scope, SCOPE.COMPLETE);
});
test('#3183 row 15: scope is UNREADABLE and getPhaseFileStats does not throw for a nonexistent dir', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const missing = path.join(tmpDir, 'does-not-exist');
assert.doesNotThrow(() => coreUtils.getPhaseFileStats(missing));
const stats = coreUtils.getPhaseFileStats(missing);
assert.strictEqual(stats.scope, SCOPE.UNREADABLE);
assert.deepEqual(stats.plans, []);
assert.deepEqual(stats.summaries, []);
assert.strictEqual(stats.hasResearch, false);
assert.strictEqual(stats.hasContext, false);
assert.strictEqual(stats.hasVerification, false);
assert.strictEqual(stats.hasReviews, false);
});
// ─── #4014 (epic #3473 B4-unreadable) matrix rows 10-11 ───────────────────
//
// getPhaseFileStats used to catch its OWN readdirSync failure and return
// `scope: scan.scope` — the scope of the UNRELATED, already-successful
// scanPhasePlans call — silently dropping that THIS function's own read
// failed. Both scanPhasePlans and getPhaseFileStats's own read call
// `fs.readdirSync(phaseDir)` on the identical path, in that order (scan
// first) — so the failure is injected on the SECOND call to that exact
// path only, letting scanPhasePlans succeed (scope complete) while
// getPhaseFileStats's own read fails, reproducing the exact swallowing bug
// named in the issue. No chmod 0o000 — root bypasses mode bits (silent
// zero coverage in root CI); restored via t.mock's auto-restore.
test('#4014 matrix row 10: own readdirSync failure is not masked by an already-successful scan.scope', (t) => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, '01-PLAN.md'), '# Plan\n');
const target = path.resolve(tmpDir);
const origReaddirSync = fs.readdirSync.bind(fs);
let callsOnTarget = 0;
t.mock.method(fs, 'readdirSync', (p, ...rest) => {
if (path.resolve(String(p)) === target) {
callsOnTarget += 1;
// 1st call = scanPhasePlans's own read: let it succeed (scope complete).
if (callsOnTarget === 1) return origReaddirSync(p, ...rest);
// 2nd call = getPhaseFileStats's own read (via findContextMdIn): fail it.
const err = new Error(`EACCES: simulated failure, scandir '${p}'`);
err.code = 'EACCES';
throw err;
}
return origReaddirSync(p, ...rest);
});
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.scope, SCOPE.UNREADABLE,
`own readdirSync failure must win over the unrelated already-successful scan.scope; got: ${stats.scope}`);
// #3183's pre-existing plans/summaries-untouched contract: unaffected
// by this function's own (unrelated) read outcome.
assert.deepEqual(stats.plans, ['01-PLAN.md']);
});
test('#4014 matrix row 11: both reads succeed — scope complete, flags computed as before (must not regress)', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(tmpDir, 'CONTEXT.md'), '# context\n');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.scope, SCOPE.COMPLETE);
assert.strictEqual(stats.hasContext, true);
assert.deepEqual(stats.plans, ['01-PLAN.md']);
});
test('#3183 row 7 regression: nested plans/PLAN-01.md ONLY is reported (used to report 0)', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
fs.mkdirSync(path.join(tmpDir, 'plans'));
fs.writeFileSync(path.join(tmpDir, 'plans', 'PLAN-01.md'), '# Plan\n');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.deepEqual(stats.plans, ['plans/PLAN-01.md']);
});
test('#3511 BLOCKER-2 regression: a cross-phase stray VERIFICATION.md is scoped out of hasVerification', () => {
// Dir is phase 03's own directory ("03-test" → token "03"); the only
// VERIFICATION.md present belongs to phase 04. scopeToPhase must exclude
// it, so hasVerification reads false — an unscoped implementation would
// wrongly report phase 03 as verified off phase 04's report.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-test-'));
const phaseDir = path.join(tmpDir, '03-test');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), '---\nstatus: passed\n---\n');
const stats = coreUtils.getPhaseFileStats(phaseDir);
assert.strictEqual(stats.hasVerification, false,
`hasVerification must be false — the only VERIFICATION.md belongs to phase 04, not 03; got scopedFiles-derived: ${stats.hasVerification}`);
});
});
// ─── extractOneLinerFromBody ──────────────────────────────────────────────────
describe('extractOneLinerFromBody', () => {
test('null → null', () => {
assert.strictEqual(coreUtils.extractOneLinerFromBody(null), null);
});
test('undefined → null', () => {
assert.strictEqual(coreUtils.extractOneLinerFromBody(undefined), null);
});
test('empty string → null', () => {
assert.strictEqual(coreUtils.extractOneLinerFromBody(''), null);
});
// #3170: extractOneLinerFromBody anchors to a Summary/Overview/Accomplishments
// heading (the function is summary-specific — both callers extract a SUMMARY
// deliverable one-liner). These fixtures use Summary-shaped headings; the
// extraction mechanism under test (heading + bold → bold, frontmatter strip,
// colon-label → prose, CRLF, unicode) is unchanged.
test('extracts bold text after a heading as one-liner', () => {
const content = '# Phase Summary\n\n**Implement the feature**\n\nMore details here.\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Implement the feature');
});
test('returns null when no bold text after heading', () => {
const content = '# Phase Summary\n\nSome prose without bold.\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), null);
});
test('strips frontmatter before searching', () => {
const content = '---\nstatus: done\n---\n# Summary\n\n**One liner here**\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'One liner here');
});
test('when bold ends with colon, returns text after the bold', () => {
const content = '# Summary\n\n**Objective:** Complete the work\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Complete the work');
});
test('CRLF line endings are normalized', () => {
const content = '# Summary\r\n\r\n**Bold line**\r\nmore\r\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Bold line');
});
test('adversarial: unicode in bold text', () => {
const content = '# Summary\n\n**中文 one-liner**\n\nMore.\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), '中文 one-liner');
});
});
// ─── timeAgo ─────────────────────────────────────────────────────────────────
describe('timeAgo', () => {
function daysAgo(n) {
return new Date(Date.now() - n * 24 * 60 * 60 * 1000);
}
function minutesAgo(n) {
return new Date(Date.now() - n * 60 * 1000);
}
function hoursAgo(n) {
return new Date(Date.now() - n * 60 * 60 * 1000);
}
function secondsAgo(n) {
return new Date(Date.now() - n * 1000);
}
test('"just now" for < 5 seconds', () => {
assert.strictEqual(coreUtils.timeAgo(secondsAgo(2)), 'just now');
});
test('"X seconds ago" for < 60 seconds', () => {
const result = coreUtils.timeAgo(secondsAgo(30));
assert.ok(result.endsWith('seconds ago'), `Expected "X seconds ago", got: ${result}`);
});
test('"1 minute ago" for ~1 minute', () => {
assert.strictEqual(coreUtils.timeAgo(minutesAgo(1)), '1 minute ago');
});
test('"X minutes ago" for < 60 minutes', () => {
const result = coreUtils.timeAgo(minutesAgo(30));
assert.ok(result.endsWith('minutes ago'), `Expected "X minutes ago", got: ${result}`);
});
test('"1 hour ago" for ~1 hour', () => {
assert.strictEqual(coreUtils.timeAgo(hoursAgo(1)), '1 hour ago');
});
test('"X hours ago" for < 24 hours', () => {
const result = coreUtils.timeAgo(hoursAgo(10));
assert.ok(result.endsWith('hours ago'), `Expected "X hours ago", got: ${result}`);
});
test('"1 day ago" for ~1 day', () => {
assert.strictEqual(coreUtils.timeAgo(daysAgo(1)), '1 day ago');
});
test('"X days ago" for < 30 days', () => {
const result = coreUtils.timeAgo(daysAgo(15));
assert.ok(result.endsWith('days ago'), `Expected "X days ago", got: ${result}`);
});
test('"1 month ago" for ~30 days', () => {
assert.strictEqual(coreUtils.timeAgo(daysAgo(30)), '1 month ago');
});
test('"X months ago" for < 12 months', () => {
const result = coreUtils.timeAgo(daysAgo(180));
assert.ok(result.endsWith('months ago'), `Expected "X months ago", got: ${result}`);
});
test('"1 year ago" for ~365 days', () => {
assert.strictEqual(coreUtils.timeAgo(daysAgo(365)), '1 year ago');
});
test('"X years ago" for multiple years', () => {
const result = coreUtils.timeAgo(daysAgo(730));
assert.ok(result.endsWith('years ago'), `Expected "X years ago", got: ${result}`);
});
});
// ─── extractCanonicalPlanId ───────────────────────────────────────────────────
describe('extractCanonicalPlanId', () => {
test('strips -PLAN.md suffix and returns basename', () => {
// '01-feature-PLAN.md' → base = '01-feature', no two adjacent phase tokens
assert.strictEqual(coreUtils.extractCanonicalPlanId('01-feature-PLAN.md'), '01-feature');
});
test('strips -SUMMARY.md suffix', () => {
assert.strictEqual(coreUtils.extractCanonicalPlanId('01-SUMMARY.md'), '01');
});
test('strips .md suffix for plain md file', () => {
assert.strictEqual(coreUtils.extractCanonicalPlanId('01.md'), '01');
});
test('returns base when no phase token found', () => {
assert.strictEqual(coreUtils.extractCanonicalPlanId('no-phase-token.md'), 'no-phase-token');
});
test('extracts canonical id with two adjacent phase tokens', () => {
// e.g. phase 01 plan 02: filename = "01-02-PLAN.md"
const result = coreUtils.extractCanonicalPlanId('01-02-PLAN.md');
assert.strictEqual(result, '01-02');
});
test('adversarial: decimal phase id tokens', () => {
// "01.1" matches the token regex (\d+[A-Z]?(\.\d+)*)
const result = coreUtils.extractCanonicalPlanId('01.1-PLAN.md');
assert.ok(typeof result === 'string');
});
test('adversarial: unicode filename returns some string', () => {
const result = coreUtils.extractCanonicalPlanId('中文-phase.md');
assert.ok(typeof result === 'string');
});
test('adversarial: path-traversal-like filename treated as literal', () => {
// extractCanonicalPlanId operates on a filename string (not a real path).
// The function does not sanitize slashes — it strips .md suffixes and
// attempts to find phase tokens. The result is a string (no crash).
const result = coreUtils.extractCanonicalPlanId('../../../etc/passwd');
assert.ok(typeof result === 'string');
assert.ok(result.length > 0);
});
test('rejects single-digit slug word (#2043)', () => {
// "46-6-rs-pipeline-orchestrator" is not a valid phase-token filename shape
// (the "6" is a slug word, not a sub-phase segment) — must not collapse to
// the pre-fix, buggy "46-6".
assert.notStrictEqual(
coreUtils.extractCanonicalPlanId('46-6-rs-pipeline-orchestrator'),
'46-6',
);
// Legit multi-segment phase-token filenames are still extracted correctly,
// including a single-digit letter-suffix phase id ("3A") — the "3A" token is
// found and paired with its zero-padded plan index, not left unpaired.
assert.strictEqual(coreUtils.extractCanonicalPlanId('01-02-PLAN.md'), '01-02');
assert.strictEqual(coreUtils.extractCanonicalPlanId('3A-01-feature-PLAN.md'), '3A-01');
});
test('does not pair a ≥3-digit slug word as a plan component (#2232)', () => {
// A year-leading slug word ("14-2026-photos-…") is not a plan component —
// must not collapse to the bogus "14-2026".
assert.notStrictEqual(
coreUtils.extractCanonicalPlanId('14-2026-photos-performance-SUMMARY.md'),
'14-2026',
);
assert.notStrictEqual(coreUtils.extractCanonicalPlanId('05-100-slug-PLAN.md'), '05-100');
// The LEADING phase component stays unbounded (\d{2,}) — only the paired
// continuation is width-capped, so phase ≥100 plan files still pair.
assert.strictEqual(coreUtils.extractCanonicalPlanId('100-01-extra-slug-PLAN.md'), '100-01');
assert.strictEqual(coreUtils.extractCanonicalPlanId('01-02-PLAN.md'), '01-02');
});
// ─── #4014 mutation-gap: the three chained suffix strips are each anchored
// ($) to the true end of the filename. An unanchored variant would instead
// strip the FIRST mid-string occurrence of the pattern, corrupting a
// filename that merely CONTAINS one of these substrings earlier on.
test('mutation-gap: mid-string ".md" is left alone — only the trailing .md is stripped', () => {
// "foo.mdx-01-PLAN.md": the trailing "-PLAN.md" is stripped by the first
// regex; the base "foo.mdx-01" does NOT end in ".md" (it ends in "mdx"
// before "-01"), so the third (bare .md$) regex must NOT touch the ".md"
// that happens to sit inside "foo.mdx". An unanchored /\.md/i would strip
// it there instead, corrupting the base to "foox-01".
assert.strictEqual(coreUtils.extractCanonicalPlanId('foo.mdx-01-PLAN.md'), 'foo.mdx-01');
});
test('mutation-gap: mid-string "-SUMMARY.md" is left alone — only the trailing form is stripped', () => {
// "foo-SUMMARY.md-01-PLAN.md": trailing "-PLAN.md" is stripped by the
// first regex, leaving "foo-SUMMARY.md-01" — which does NOT end in
// "-SUMMARY.md". An unanchored /-SUMMARY\.md/i would still strip the
// mid-string occurrence, corrupting the base to "foo-01".
assert.strictEqual(coreUtils.extractCanonicalPlanId('foo-SUMMARY.md-01-PLAN.md'), 'foo-SUMMARY.md-01');
});
});
// ─── countMatchedSummaries (#1988) ───────────────────────────────────────────
// Lives in core-utils.test.cjs (not roadmap.test.cjs) so the Stryker core-utils
// shard — which runs only this file — actually covers the helper's mutants.
describe('countMatchedSummaries — stray non-plan summaries excluded (#1988)', () => {
const { countMatchedSummaries } = coreUtils;
test('counts only summaries that are the PLAN→SUMMARY partner of a plan', () => {
const plans = ['30-01-PLAN.md', '30-02-PLAN.md', '30-10-PLAN.md'];
const summaries = ['30-01-SUMMARY.md', '30-FIX-CR02-SUMMARY.md', '30-GAPCLOSURE-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 1);
});
test('all plans have a partner summary → counts every plan', () => {
const plans = ['01-01-PLAN.md', '01-02-PLAN.md'];
const summaries = ['01-01-SUMMARY.md', '01-02-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 2);
});
test('nested layout pairing preserved (plans/PLAN-NN ↔ plans/SUMMARY-NN)', () => {
const plans = ['plans/PLAN-01.md', 'plans/PLAN-02.md'];
const summaries = ['plans/SUMMARY-01.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 1);
});
test('extended layout pairing (N-PLAN-MM-slug ↔ N-MM-SUMMARY)', () => {
const plans = ['3-PLAN-01-setup.md', '5-PLAN-02-migrations.md'];
const summaries = ['3-01-SUMMARY.md', '5-02-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 2);
});
test('extended layout: only the matching plan counts (no cross-pairing)', () => {
const plans = ['3-PLAN-01-setup.md', '3-PLAN-02-seed.md'];
const summaries = ['3-01-SUMMARY.md']; // only plan 01 has a summary
assert.strictEqual(countMatchedSummaries(plans, summaries), 1);
});
test('bare PLAN.md ↔ SUMMARY.md', () => {
assert.strictEqual(countMatchedSummaries(['PLAN.md'], ['SUMMARY.md']), 1);
assert.strictEqual(countMatchedSummaries(['PLAN.md'], []), 0);
});
test('bare PLAN.md ↔ PLAN-SUMMARY.md (stem-suffix convention)', () => {
assert.strictEqual(countMatchedSummaries(['PLAN.md'], ['PLAN-SUMMARY.md']), 1);
});
test('legacy <N>-PLAN-<NN> ↔ <N>-PLAN-<NN>-SUMMARY', () => {
const plans = ['14-PLAN-01.md', '14-PLAN-02.md'];
const summaries = ['14-PLAN-01-SUMMARY.md', '14-PLAN-02-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 2);
});
test('stray summaries never count when no plan partners exist', () => {
const plans = ['30-01-PLAN.md'];
const strays = ['30-FIX-CR02-SUMMARY.md', '30-GAPCLOSURE-SUMMARY.md', '30-01-EXTRA-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, strays), 0);
});
test('absolute path (leading slash) still pairs via the dir split', () => {
// Guards the lastIndexOf('/') >= 0 boundary (slash at index 0).
assert.strictEqual(countMatchedSummaries(['/abs/PLAN-01.md'], ['/abs/SUMMARY-01.md']), 1);
});
});
// ─── #4014 mutation-gap: summaryCandidates / findUnsummarizedPlans /
// findOrphanSummaries — same "Stryker core-utils shard runs only THIS file"
// reasoning as countMatchedSummaries above. These three share the private
// summaryCandidates helper but, unlike countMatchedSummaries, had no direct
// coverage inside core-utils.test.cjs itself (their existing coverage lives
// in plan-count-single-owner.test.cjs / summary-status-blocked-3345.test.cjs,
// neither of which the core-utils Stryker shard executes).
describe('summaryCandidates / findUnsummarizedPlans / findOrphanSummaries — #4014 mutation-gap', () => {
const { countMatchedSummaries, findUnsummarizedPlans, findOrphanSummaries } = coreUtils;
test('mutation-gap: mid-string ".md" in the plan filename does not corrupt the base', () => {
// "my.mdx-file-PLAN.md": summaryCandidates' own `base` strip is anchored
// (/\.md$/i) so only the true trailing ".md" is removed, leaving
// "my.mdx-file-PLAN" — the marker-swap candidate is then
// "my.mdx-file-SUMMARY.md". An unanchored strip would instead corrupt the
// base by stripping the ".md" that sits inside "my.mdx" first.
assert.strictEqual(
countMatchedSummaries(['my.mdx-file-PLAN.md'], ['my.mdx-file-SUMMARY.md']),
1,
);
assert.deepEqual(findUnsummarizedPlans(['my.mdx-file-PLAN.md'], ['my.mdx-file-SUMMARY.md']), []);
});
test('mutation-gap: legacy extended form <n>-PLAN-<m> matches ONLY via the <n>-<m>-SUMMARY candidate', () => {
// "14-PLAN-01.md" also generates a marker-swap candidate
// ("14-SUMMARY-01.md") and a stem-suffix candidate
// ("14-PLAN-01-SUMMARY.md"), but the summary file used here
// ("14-01-SUMMARY.md") matches NEITHER of those — it can only match via
// the `extended` candidate on the summaryCandidates line that special-
// cases `^(\d+)-PLAN-(\d+)`. Isolates that candidate from the other two.
assert.strictEqual(countMatchedSummaries(['14-PLAN-01.md'], ['14-01-SUMMARY.md']), 1);
assert.deepEqual(findUnsummarizedPlans(['14-PLAN-01.md'], ['14-01-SUMMARY.md']), []);
});
test('mutation-gap: #3183 canonical-id candidate fires only when it differs from the PLAN-stripped stem', () => {
// "68-01-scaffolding-PLAN.md": extractCanonicalPlanId pairs "68"+"01" into
// "68-01", which differs from the plain PLAN-stripped stem
// "68-01-scaffolding" — so the `canonicalId !== planStem` guard fires and
// pushes the "68-01-SUMMARY.md" candidate. The summary here matches ONLY
// through that candidate (not the marker-swap or stem-suffix forms), so
// flipping the guard's equality in either direction breaks this match.
assert.strictEqual(
countMatchedSummaries(['68-01-scaffolding-PLAN.md'], ['68-01-SUMMARY.md']),
1,
);
assert.deepEqual(findUnsummarizedPlans(['68-01-scaffolding-PLAN.md'], ['68-01-SUMMARY.md']), []);
});
test('mutation-gap: findUnsummarizedPlans on a MIXED set returns exactly the unsummarized subset', () => {
// An all-matched or all-unmatched fixture can't distinguish a `.filter()`
// that was mutated to always-true/always-false/identity from a correctly
// behaving one — only a mixed set, checked by exact array identity, can.
const plans = ['01-01-PLAN.md', '01-02-PLAN.md', '01-03-PLAN.md'];
const summaries = ['01-01-SUMMARY.md', '01-03-SUMMARY.md']; // 01-02 has none
assert.deepEqual(findUnsummarizedPlans(plans, summaries), ['01-02-PLAN.md']);
});
test('mutation-gap: findOrphanSummaries on a MIXED set returns exactly the unclaimed subset', () => {
const plans = ['01-01-PLAN.md', '01-02-PLAN.md'];
const summaries = ['01-01-SUMMARY.md', '01-02-SUMMARY.md', '01-STRAY-SUMMARY.md'];
assert.deepEqual(findOrphanSummaries(plans, summaries), ['01-STRAY-SUMMARY.md']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3883 (ADR-3473 §8.3) — one slug implementation per rule
//
// generateSlugInternal (this file's own subject, above) is the canonical slug
// formula. 11 independent inline re-implementations were found by grep across
// src/ (file:line evidence in the PR description). This block drives each
// reachable site the way production reaches it and proves today's code
// disagrees with the canonical — failing-first, per the phase 6 test matrix
// (.msd/phase/feat-3883-one-impl-per-rule/50-test-matrix.md section A/B).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3883 one-impl-per-rule: slug re-implementation divergence', () => {
// ── A1: commands.cts cmdGenerateSlug (CLI `generate-slug`) ──────────────────
test('A1 cyrillicSlugIsNotEmpty: cmdGenerateSlug on Cyrillic yields the canonical value, not ""', () => {
const observedSlug = cmdGenerateSlugInProcess('Привет мир');
// Pinned to a concrete value (not merely "non-empty") so this cannot be
// satisfied by an unrelated fallback string — and pinned to the
// canonical's OWN live output so a future change to the transliteration
// map cannot silently desync this expectation from generateSlugInternal.
const canonical = coreUtils.generateSlugInternal('Привет мир');
assert.strictEqual(canonical, 'privet-mir', 'sanity: canonical must itself transliterate to privet-mir');
assert.notStrictEqual(observedSlug, '', 'cmdGenerateSlug must not collapse a Cyrillic title to an empty slug (#2848-class regression)');
assert.strictEqual(observedSlug, canonical, 'cmdGenerateSlug must delegate to (or agree with) generateSlugInternal');
});
// ── A2: commands.cts cmdGenerateSlug truncation-boundary trailing hyphen ────
test('A2 truncationBoundaryLeavesNoTrailingHyphen: no trailing separator at the truncation boundary', () => {
// #2849: strip-then-truncate (cmdGenerateSlug's current order) can
// reintroduce a trailing hyphen when truncation lands exactly on an
// internal separator that strip-then-truncate never revisits.
// generateSlugInternal fixed this by truncating BEFORE the final strip
// pass (see its own comment, above in this file). The boundary is
// reproduced with 59 'a's + ' b': the collapsed separator sits exactly
// at the canonical's substring(0, 60) cut point.
const input = 'a'.repeat(59) + ' b';
const observedSlug = cmdGenerateSlugInProcess(input);
const canonical = coreUtils.generateSlugInternal(input);
assert.strictEqual(canonical, 'a'.repeat(59), 'sanity: canonical must not leave a trailing hyphen at the boundary');
assert.ok(!observedSlug.endsWith('-'), `cmdGenerateSlug must not emit a trailing hyphen at the truncation boundary; got: ${JSON.stringify(observedSlug)}`);
assert.strictEqual(observedSlug, canonical, 'cmdGenerateSlug must agree with generateSlugInternal at the truncation boundary');
});
// ── A3/A4: every reachable slug call site vs. the canonical ─────────────────
// Shared input corpus: ASCII, Cyrillic, CJK, emoji, punctuation runs,
// leading/trailing separators. Boundary-length inputs are a SEPARATE
// corpus (below) because not every site truncates (A4).
const CORPUS = [
['ascii-simple', 'Hello World'],
['ascii-punctuation-run', 'Test@#$%^Special!!!'],
['ascii-leading-trailing-separators', '---Leading Trailing---'],
['ascii-numbers', 'Phase 3 Plan'],
['cyrillic', 'Привет мир'],
['cjk-non-transliterable', '中文测试'],
['emoji', 'hello 😀 world'],
['latin-diacritics', 'Café münchen'],
];
// Boundary corpus: the separator sits one-under / exactly-at / one-over the
// canonical's substring(0, 60) cut point (see A2 and B1 below).
const BOUNDARY_CORPUS = [
['boundary-under', 'a'.repeat(58) + ' b'],
['boundary-exact', 'a'.repeat(59) + ' b'],
['boundary-over', 'a'.repeat(60) + ' b'],
];
// #3883 regression corpus: inputs well past the OLD hard-coded 60-char cap,
// for sites whose pre-migration contract never truncated (cap === null).
// Proves the untruncated tail actually survives, not merely that the
// truncation boundary is handled.
const LONG_UNCAPPED_CORPUS = [
['long-ascii-70', 'a'.repeat(70)],
['long-ascii-100-with-separators', `${'word-'.repeat(20)}tail`],
['long-ascii-90-mixed-case', 'The Quick Brown Fox Jumps Over The Lazy Dog Many Many Many Times In A Row'],
];
// cmdInitExecutePhase / cmdInitPhaseOp emit `phase_slug: phaseInfo?.['phase_slug'] || null`
// (init.cts:1880 and neighbors) — an output-shaping `|| null` that coerces
// ANY empty-string slug to null, independent of whether the slugification
// itself was correct. Comparing the raw JSON value against the canonical's
// real string output would flag every all-non-transliterable corpus entry
// (e.g. CJK, where the canonical ALSO produces "") as a false divergence.
// Normalizing null back to "" here isolates the axis this test actually
// cares about — the slug ALGORITHM's output — from that unrelated
// presentation choice.
function phaseSlugField(json) {
return json.phase_slug === null ? '' : json.phase_slug;
}
// Each site: { name, cap, call(text) => observed slug value }. `cap` is the
// site's OWN pre-#3883-migration truncation contract (60, or null for
// "never truncated") — the value it must now be compared against, not a
// single global 60. #3883-remediation (this file, security-review finding):
// the original A3/A4 harness compared every migrated site to
// generateSlugInternal(text) with the DEFAULT 60 cap baked in, which could
// only ever prove "truncates like the default" — it structurally could not
// catch the two sites (phase-id.cts toDir, workstream-name-policy.cts
// toWorkstreamSlug) that the migration silently truncated for the first
// time, because a false-positive "matches the canonical" was the only
// possible outcome once BOTH sides shared the same hard-coded 60. `call`
// drives the site exactly the way production reaches it.
const SITES = [
{
name: 'commands.cts:209 cmdGenerateSlug (CLI generate-slug)',
cap: 60,
call(text) {
return cmdGenerateSlugInProcess(text);
},
},
{
name: 'init.cts:176 slugifyPhaseName (CLI init execute-phase, ROADMAP-only phase)',
cap: null,
call(text) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3883-ep-'));
try {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
);
const json = withHermeticInProcessEnv(tmpDir, () => {
const captured = captureFd1Sync(() => initMod.cmdInitExecutePhase(tmpDir, '1', false));
return JSON.parse(captured);
});
return phaseSlugField(json);
} finally {
cleanup(tmpDir);
}
},
},
{
name: 'init.cts:1957 cmdInitPhaseOp !phaseInfo fallback (CLI init phase-op, no directory)',
cap: null,
call(text) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3883-po-fb-'));
try {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
);
const json = withHermeticInProcessEnv(tmpDir, () => {
const captured = captureFd1Sync(() => initMod.cmdInitPhaseOp(tmpDir, '1', false));
return JSON.parse(captured);
});
return phaseSlugField(json);
} finally {
cleanup(tmpDir);
}
},
},
{
name: 'init.cts:1935 cmdInitPhaseOp archived branch (CLI init phase-op, archived dir + current ROADMAP)',
cap: null,
call(text) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3883-po-ar-'));
try {
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-old');
fs.mkdirSync(archiveDir, { recursive: true });
fs.writeFileSync(path.join(archiveDir, '01-CONTEXT.md'), '# old');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n<details>\n<summary>Shipped v1.0</summary>\n\n### Phase 1: Old\n**Goal:** old\n</details>\n\n## Current\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
);
const json = withHermeticInProcessEnv(tmpDir, () => {
const captured = captureFd1Sync(() => initMod.cmdInitPhaseOp(tmpDir, '1', false));
return JSON.parse(captured);
});
return phaseSlugField(json);
} finally {
cleanup(tmpDir);
}
},
},
{
name: 'init.cts:3109 cmdInitProgress unstarted ROADMAP phase (CLI init progress)',
cap: null,
call(text) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3883-prog-'));
try {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
);
const json = withHermeticInProcessEnv(tmpDir, () => {
const captured = captureFd1Sync(() => initMod.cmdInitProgress(tmpDir, false));
return JSON.parse(captured);
});
return json.phases[0].name;
} finally {
cleanup(tmpDir);
}
},
},
{
name: 'phase-id.cts:229 getPhaseDirFromPhaseId (direct require, exported)',
cap: null,
call(text) {
// The rendered dir is `<milestone>-<sub>-<slug>`; strip the fixed
// numeric prefix this call always emits to isolate the slug component.
const dir = phaseId.getPhaseDirFromPhaseId('01-01', text, null);
return dir.replace(/^01-01-?/, '');
},
},
{
name: 'phase-id.cts:380 toDir safeSlug guard (direct require, exported)',
cap: null,
call(text) {
try {
const dir = phaseId.toDir({ project: 'MSD', milestone: '01', phase: '01' }, text);
return dir.replace(/^MSD\.01-01-?/, '');
} catch (e) {
// #3883 declared difference (axis: empty-sanitize-guard,
// phase-id.cts:380 toDir docstring, "toDir: slug sanitizes to
// empty"): toDir intentionally THROWS rather than emit an unusable
// directory name when a slug sanitizes to "" — deliberate, not a
// bug to consolidate away, because a slug here becomes a real
// on-disk path segment (parsePhaseId's dir<->identity bijection;
// every other slug call site just accepts "" silently). Post-
// migration, toDir now shares the canonical's OWN transliteration
// + truncation, so it throws in exactly the cases the canonical
// itself would produce "" (CJK-only / punctuation-only / emoji-
// only input — see B3) and NOT in any case the canonical succeeds
// (Cyrillic — the #2848-class defect this migration fixes).
// Surfaced as the canonical's own "" here so the corpus loop still
// demands real agreement everywhere the canonical succeeds, and
// only tolerates the throw where the canonical's answer is itself
// "". Assert the SPECIFIC sentinel throw, not any exception — a
// row that accepted an unrelated crash (e.g. a TypeError from a
// regression elsewhere in toDir) as if it were the declared
// empty-sanitize guard would pass while testing nothing.
assert.ok(
e instanceof Error && e.message.startsWith('toDir: slug sanitizes to empty'),
`expected toDir's declared "toDir: slug sanitizes to empty" guard, got: ${e && e.message}`,
);
const canonical = coreUtils.generateSlugInternal(text, null);
if (canonical === '' || canonical === null) return canonical ?? '';
return `__THREW__:${e.message}`;
}
},
},
{
name: 'phase-locator.cts:269 findPhaseInternal phase_slug (direct require, exported)',
cap: null,
call(text) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3883-pl-'));
try {
const phaseDir = path.join(tmpDir, '.planning', 'phases', `01-${text}`);
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
return result ? result.phase_slug : undefined;
} finally {
cleanup(tmpDir);
}
},
},
{
name: 'workstream-name-policy.cts:75 toWorkstreamSlug (direct require, exported)',
// #3883 regression (fixed): this site never truncated pre-migration.
// generateSlugInternal's `maxLen` parameter now lets it opt out of the
// 60-char default instead of colliding distinct >60-char names.
cap: null,
call(text) {
return workstreamNamePolicy.toWorkstreamSlug(text);
},
},
];
// A4: sites intentionally different from the canonical, with their reason.
// A3 skips these for the axis named, an UNDECLARED divergence still fails.
const DECLARED_DIFFERENT = [
{
site: 'gsd2-import.cts:97 slugify (direct require, exported)',
axis: 'truncation',
reason:
'Documented distinct contract (gsd2-import.cts:97-102, tests/gsd2-import.test.cjs '
+ '"#2848 row 11"): slugify shares the transliteration primitive with '
+ 'generateSlugInternal but deliberately does NOT truncate at 60 chars — '
+ 'GSD-2 import titles are not filesystem path segments the way phase '
+ 'directory slugs are.',
},
{
site: 'active-workstream-store.cts:97 getWorkstreamSessionKey (direct require, exported)',
axis: 'input-domain',
reason:
'The slugified text is never caller-supplied: it is always one of a '
+ 'fixed ASCII whitelist of environment-variable KEY NAMES '
+ '(WORKSTREAM_SESSION_ENV_KEYS, all 13 entries: MSD_SESSION_KEY, '
+ 'CODEX_THREAD_ID, CLAUDE_SESSION_ID, CLAUDE_CODE_SESSION_ID, '
+ 'CLAUDE_CODE_SSE_PORT, OPENCODE_SESSION_ID, GEMINI_SESSION_ID, '
+ 'CURSOR_SESSION_ID, WINDSURF_SESSION_ID, TERM_SESSION_ID, WT_SESSION, '
+ 'TMUX_PANE, ZELLIJ_SESSION_NAME). The shared unicode/CJK/emoji/'
+ 'boundary-length corpus can never reach this call site in '
+ 'production, so it is checked separately below against its own real '
+ 'input domain rather than run through the shared CORPUS loop.',
},
{
site: 'phase-id.cts:380 toDir safeSlug guard (direct require, exported)',
axis: 'empty-sanitize-guard',
reason:
'toDir (phase-id.cts:380, docstring above toDir) intentionally THROWS '
+ '"toDir: slug sanitizes to empty" instead of emitting an unusable '
+ 'on-disk directory name — this is a disk-naming call site that '
+ 'protects the parsePhaseId dir<->identity bijection (an empty slug '
+ 'would leave a dangling trailing hyphen; every other slug call site '
+ 'silently accepts ""). Migrated to share the canonical\'s own '
+ 'transliteration + truncation, so the throw now fires in EXACTLY the '
+ 'cases the canonical itself reduces to "" (CJK/punctuation/emoji-only '
+ '— see B3), and never in a case the canonical succeeds (Cyrillic — '
+ 'the #2848-class defect this migration fixes for every other site).',
},
];
test('A4 deliberatelyDifferentSitesAreDeclared: the declared-divergence list names real sites with real reasons', () => {
// This mechanism must exist even though today it is non-empty (both
// entries above are load-bearing — remove either and A3 below starts
// failing for the corresponding site/axis, which is exactly the point:
// A3 cannot be silently satisfied by exempting the hard cases).
assert.ok(Array.isArray(DECLARED_DIFFERENT));
for (const entry of DECLARED_DIFFERENT) {
assert.strictEqual(typeof entry.site, 'string');
assert.ok(entry.site.length > 0);
assert.strictEqual(typeof entry.reason, 'string');
assert.ok(entry.reason.length > 20, 'a declared-different entry needs a real reason, not a placeholder');
}
const declaredForTruncation = DECLARED_DIFFERENT.filter((e) => e.axis === 'truncation').map((e) => e.site);
assert.deepStrictEqual(
declaredForTruncation,
['gsd2-import.cts:97 slugify (direct require, exported)'],
'exactly one site is declared to skip the truncation axis — an undeclared truncation gap must fail A3',
);
});
describe('A3 everySlugCallSiteAgreesWithTheCanonical', () => {
for (const site of SITES) {
describe(site.name, () => {
for (const [label, text] of CORPUS) {
test(`corpus:${label} agrees with generateSlugInternal`, () => {
const canonical = coreUtils.generateSlugInternal(text);
const observed = site.call(text);
assert.strictEqual(
observed,
canonical,
`site "${site.name}" on ${JSON.stringify(text)}: expected canonical ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`,
);
});
}
if (site.cap !== null) {
for (const [label, text] of BOUNDARY_CORPUS) {
test(`boundary:${label} agrees with generateSlugInternal(text, ${site.cap})`, () => {
const canonical = coreUtils.generateSlugInternal(text, site.cap);
const observed = site.call(text);
assert.strictEqual(
observed,
canonical,
`site "${site.name}" at truncation boundary ${JSON.stringify(text)}: expected canonical ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`,
);
});
}
} else {
// #3883 regression coverage: a site whose pre-migration contract
// never truncated must still never truncate post-migration — prove
// it on inputs well past the OLD hard-coded 60-char cap, not just
// at the boundary. This is the exact axis the original harness
// could not see (it always compared against a 60-capped canonical).
for (const [label, text] of LONG_UNCAPPED_CORPUS) {
test(`long:${label} agrees with generateSlugInternal(text, null) and is not truncated to 60`, () => {
const canonical = coreUtils.generateSlugInternal(text, null);
const observed = site.call(text);
assert.strictEqual(
observed,
canonical,
`site "${site.name}" on long input ${JSON.stringify(text)}: expected untruncated canonical ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`,
);
});
}
}
});
}
// #3883 collision regression: the two concrete collisions the security
// review proved by execution (workstream-name-policy.cts toWorkstreamSlug,
// phase-id.cts toDir) — driven through the REAL surfaces, not the
// canonical directly, and asserted RED against 01cc283da / GREEN after
// generateSlugInternal gained the maxLen parameter and these sites opted
// out of the 60-char default.
describe('A5 uncappedSitesDoNotCollideOnLongInputs (#3883 collision regression)', () => {
const COLLISION_PAIRS = [
[`${'a'.repeat(60)}alpha`, `${'a'.repeat(60)}beta`],
[
'migrate the legacy billing subsystem onto the new distributed queue system today',
'migrate the legacy billing subsystem onto the new distributed queue system tomorrow',
],
];
for (const site of SITES.filter((s) => s.cap === null)) {
describe(site.name, () => {
for (const [textA, textB] of COLLISION_PAIRS) {
test(`"${textA.slice(0, 20)}..." vs "${textB.slice(0, 20)}..." produce distinct slugs`, () => {
const slugA = site.call(textA);
const slugB = site.call(textB);
assert.notEqual(
slugA,
slugB,
`site "${site.name}": distinct >60-char inputs collided on slug ${JSON.stringify(slugA)}`,
);
});
}
});
}
});
// gsd2-import.cts:97 slugify — compared on the general corpus (must still
// transliterate correctly) but NOT the boundary corpus (declared, A4).
describe('gsd2-import.cts:97 slugify (direct require, exported)', () => {
for (const [label, text] of CORPUS) {
test(`corpus:${label} agrees with generateSlugInternal`, () => {
const canonical = coreUtils.generateSlugInternal(text);
const observed = gsd2Import.slugify(text);
assert.strictEqual(observed, canonical, `slugify on ${JSON.stringify(text)}: expected ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`);
});
}
});
// active-workstream-store.cts:97 getWorkstreamSessionKey — checked against
// its own real, restricted input domain (declared, A4), not the shared corpus.
describe('active-workstream-store.cts:97 getWorkstreamSessionKey (real input domain only)', () => {
const REAL_ENV_KEYS = [
'MSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT', 'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID',
'WINDSURF_SESSION_ID', 'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
];
for (const envKey of REAL_ENV_KEYS) {
test(`${envKey} slugifies identically to generateSlugInternal(${envKey})`, () => {
const saved = {};
for (const k of REAL_ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
process.env[envKey] = 'token123';
try {
const observedKey = activeWorkstreamStore.getWorkstreamSessionKey();
const canonicalPrefix = coreUtils.generateSlugInternal(envKey);
assert.strictEqual(
observedKey,
`${canonicalPrefix}-token123`,
`getWorkstreamSessionKey(${envKey}): expected the envKey portion to equal generateSlugInternal(${envKey})`,
);
} finally {
for (const k of REAL_ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
}
}
});
}
});
});
// ── B. Boundaries (repo rule: limit-1, limit, limit+1) ───────────────────────
describe('B1 slug truncation length: one-under / exact / one-over — no trailing separator at any', () => {
for (const [label, text] of BOUNDARY_CORPUS) {
test(`canonical: ${label}`, () => {
const slug = coreUtils.generateSlugInternal(text);
assert.ok(!slug.endsWith('-'), `generateSlugInternal(${JSON.stringify(text)}) must not end in a hyphen; got ${JSON.stringify(slug)}`);
});
}
});
describe('B2 empty and whitespace-only input', () => {
test('generateSlugInternal("") → null', () => {
assert.strictEqual(coreUtils.generateSlugInternal(''), null);
});
test('generateSlugInternal(" ") → "" (whitespace collapses, not null — falsy check is on the input, not the output)', () => {
assert.strictEqual(coreUtils.generateSlugInternal(' '), '');
});
test('cmdGenerateSlug rejects empty input with an explicit error (not a silent empty slug)', () => {
const result = runMsdTools(['generate-slug', ''], process.cwd(), { HOME: os.tmpdir() });
assert.ok(!result.success, 'generate-slug must fail on empty text');
assert.ok(result.error.includes('text required'), `expected "text required" error, got: ${result.error}`);
});
});
describe('B3 input that is entirely non-transliterable (Cyrillic-to-empty class, generalized)', () => {
test('CJK-only title → canonical produces "" (not an error, not a crash)', () => {
assert.strictEqual(coreUtils.generateSlugInternal('中文测试'), '');
});
test('punctuation-only title → canonical produces ""', () => {
assert.strictEqual(coreUtils.generateSlugInternal('!!!@@@###'), '');
});
test('emoji-only title → canonical produces ""', () => {
assert.strictEqual(coreUtils.generateSlugInternal('😀😁😂'), '');
});
});
});