Files
msd-core/tests/core-utils.test.cjs
Tom Boucher d2d2f7c088 fix(#2848): non-Latin titles no longer produce empty slugs (Cyrillic transliteration) (#2934)
* test(#2848): add failing-first regression for non-Latin slug transliteration

generateSlugInternal and slugify both strip non-ASCII chars with no
transliteration step, so an all-Cyrillic title reduces to an empty slug.
12-row matrix: Cyrillic regression (both impls), Latin negative control,
multi-letter mappings, soft/hard sign drops, Ukrainian extras, null
contract, CJK unaffected, mixed scripts, truncation parity, slugify's
distinct no-truncate contract.

* fix(#2848): transliterate Cyrillic titles to ASCII before slug strip

Both generateSlugInternal (src/core-utils.cts) and slugify
(src/gsd2-import.cts) stripped non-ASCII with no transliteration, so an
all-Cyrillic title reduced to an empty slug, producing unnamed phase
directories (01-) and empty milestone_slug init JSON.

Add a shared transliterateForSlug primitive (core-utils) covering Russian
+ the reported Ukrainian/Belarusian extras (і ї є ґ ў), with multi-letter
mappings (ж→zh ч→ch ш→sh щ→sch ю→yu я→ya) and dropped soft/hard signs
(ъ ь). It runs BEFORE the existing ASCII filter, so Latin-script text
hits zero map entries and is byte-for-byte unchanged (negative control).
slugify consumes the shared primitive, preserving its distinct single
hyphen-strip + no-truncation contract. CJK/unmapped scripts keep the
existing strip-to-ASCII behavior.

Also corrects two test assertions to match the chosen й→y mapping and the
б→b (not bie) transliteration.

* changeset(#2848): Fixed — non-Latin slug transliteration

* changeset(#2848): backfill PR number 2934

---------

Co-authored-by: sim <sim@local>
2026-07-31 10:56:11 -04:00

702 lines
30 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 gsd-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
* - filterPlanFiles
* - filterSummaryFiles
* - 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('../gsd-core/bin/lib/core-utils.cjs');
const { cleanup } = require('./helpers.cjs');
// ─── 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(), 'gsd-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(), 'gsd-cu-test-'));
const subDir = path.join(tmpDir, 'myrepo');
fs.mkdirSync(subDir);
fs.mkdirSync(path.join(subDir, '.git'));
assert.deepEqual(coreUtils.detectSubRepos(tmpDir), ['myrepo']);
});
test('excludes hidden directories', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-cu-test-'));
assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'nope.txt'), false);
});
test('handles absolute targetPath', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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(), 'gsd-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);
});
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}`);
});
});
// ─── filterPlanFiles ──────────────────────────────────────────────────────────
describe('filterPlanFiles', () => {
test('returns only PLAN.md and *-PLAN.md files', () => {
const files = ['PLAN.md', '01-PLAN.md', 'SUMMARY.md', 'README.md', 'foo-PLAN.md'];
assert.deepEqual(coreUtils.filterPlanFiles(files), ['PLAN.md', '01-PLAN.md', 'foo-PLAN.md']);
});
test('empty array → empty array', () => {
assert.deepEqual(coreUtils.filterPlanFiles([]), []);
});
test('no matching files → empty array', () => {
assert.deepEqual(coreUtils.filterPlanFiles(['SUMMARY.md', 'CONTEXT.md']), []);
});
test('case-sensitive: plan.md is not matched', () => {
assert.deepEqual(coreUtils.filterPlanFiles(['plan.md', 'Plan.md']), []);
});
});
// ─── filterSummaryFiles ───────────────────────────────────────────────────────
describe('filterSummaryFiles', () => {
test('returns only SUMMARY.md and *-SUMMARY.md files', () => {
const files = ['SUMMARY.md', '01-SUMMARY.md', 'PLAN.md', 'foo-SUMMARY.md'];
assert.deepEqual(coreUtils.filterSummaryFiles(files), ['SUMMARY.md', '01-SUMMARY.md', 'foo-SUMMARY.md']);
});
test('empty array → empty array', () => {
assert.deepEqual(coreUtils.filterSummaryFiles([]), []);
});
test('no matching files → empty array', () => {
assert.deepEqual(coreUtils.filterSummaryFiles(['PLAN.md', 'CONTEXT.md']), []);
});
});
// ─── 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(), 'gsd-cu-test-'));
assert.deepEqual(coreUtils.readSubdirectories(tmpDir), []);
});
test('returns only directory names, not files', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-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(), 'gsd-cu-test-'));
fs.writeFileSync(path.join(tmpDir, 'CONTEXT.md'), '');
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.strictEqual(stats.hasContext, true);
});
});
// ─── 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);
});
test('extracts bold text after a heading as one-liner', () => {
const content = '# Phase Title\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 Title\n\nSome prose without bold.\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), null);
});
test('strips frontmatter before searching', () => {
const content = '---\nstatus: done\n---\n# Title\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 = '# Title\n\n**Objective:** Complete the work\n';
assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Complete the work');
});
test('CRLF line endings are normalized', () => {
const content = '# Title\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 = '# Title\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');
});
});
// ─── 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);
});
});