* refactor(#3183): route live-plan counting through scanPhasePlans scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one independent re-derivations across seven modules now route through it, and scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather than an allowlist (ADR-3180 Decision 4a). The epic scoped this at three copies. A whole-repo guard found twenty-six sites across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3 narrows to window plus sentinel enumeration. Two sites are exempt with a documented reason rather than a bare allowlist: audit.cts scans one quick task's own directory for a single completion record, and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time import. Neither is a phase directory. scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one owner answers both questions: verify.cts's numbering-gap check wants every plan on disk, its pairing check wants the live set. Both fields are additive. Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave scheduling, was scheduling status:superseded plans into waves and reporting zero plans for the post-#3139 nested layout. filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned them and only their own tests still called them. New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator, with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice The contract held; the phase boundary did not. The whole-repo drift guard found 26 re-derivations across 9 files against the epic's estimate of 3, and cmdProgressRender re-derives both enumeration and plan counting on adjacent lines, so DW4 was unsatisfiable within Phase 1's original file scope. Records the amended scope, scanPhasePlans's new allPlanFiles field, findOrphanSummaries, the two documented exemptions, the re-derived Tier-2 table, and the describeNonCanonicalPlans trap for later phases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): complete the canonical pairing rule and gate the naming diagnostic The remote runner went red with 13 deterministic failures on both lanes, and they were right: replacing verify.cts's canonicalPlanStem pairing with summaryCandidates dropped a case the bespoke rule covered. A plan carrying a descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no such candidate, so the plan read unsummarized. The fix is to complete the one rule rather than restore a second: summaryCandidates gains a canonical-id candidate, narrowed to fire only when an id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans and findOrphanSummaries all inherit it. The two-plans-one-summary collision behaviour of the original rule is preserved deliberately and documented in place. Second defect, independently root-caused while verifying: routing the #2893 naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i fallback, which is correct for counting and wrong for a naming check — a non-canonically-named file was accepted as a valid plan and the diagnostic went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect with a strict isCanonicalPlanFile predicate before reporting names. Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180: a question about file naming wants the physical, strictly-matched set; only a question about outstanding work wants the live set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): register planning-scope.cjs in the eslint migration list tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor ignored per its ADR-457 migration state. The new planning-scope module closed five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary - but not eslint, because that one is enforced by a test rather than by lint:ci, so the local pipeline stayed green while it was missing. Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is linted, matching every other migrated module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): replace the plan-count drift detector with a literal tokenizer CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an escaped \.md". Five review rounds found it had two defects, not one: - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.` pair be consumed either as one escape or as two class characters, which is exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`. Excluding `\` from the class killed that but left a cubic path — 23ms at N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI runs on fork pull requests, so a crafted src/*.cts could stall the job. - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g. `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the literal at that `/`, so the scan never reached `\.md` and the guard missed it entirely. (Classes holding an ESCAPED `\/` were already matched; the tests cover those separately as parity, not as regressions.) Both defects have one root cause: regex-literal grammar — `\x` escapes, and `/` inside `[...]` not terminating — is not expressible in a backtracking regex. So the detector is now a tokenizer, not a regex. readRegexLiteralAt reads the literal at a given `/` in a single left-to-right pass with no backtracking, treating escapes as two-character units and suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch restarts it at every `/` on the line, preserving the old "find anywhere" behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the trailing-flag scan — which keeps the whole-line cost linear. Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now caught. Differential against the old regex over 28,474 lines (those matching FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/ gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the old regex hangs): 6 differences, all the tokenizer returning the fuller or newly-correct literal, 0 old-only misses. The `\.md` token stays case-insensitive, matching the `/i` the old regex carried. Also closes three holes in the same new file: - walk() tested entry.isFile(), false for a symlink, so a symlinked src/*.cts was silently unscanned — an evasion of a guard whose stated principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist. It now resolves symlinks, but confined: file links must resolve inside the repo root, directory links inside the scanned dir itself. Every sibling drift guard in scripts/ uses the Dirent classification and never follows links, so following them unconfined would have made this the only linter able to read outside the tree — on fork PRs an arbitrary out-of-repo read whose matched fragments reach a public CI log. The narrower directory rule additionally stops `src/up -> ..` from sweeping the whole repo, and the skip list is now checked against resolved paths so `src/g -> ../.git` cannot reach .git/** or node_modules/**. Real paths are de-duplicated and files reported canonically, so a symlink alias cannot shift which FUNCTION_SCOPED_EXEMPTIONS key applies. - Both the reported fragment and the reported FILE PATH are attacker- controlled source text written straight to a CI log, and git permits control bytes in a filename. Both are now escaped — C0/C1/DEL plus the bidi and zero-width controls — so a crafted literal or filename cannot recolour the log, overwrite a line with CR, or fabricate a line that looks like this guard's own success output. Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process probe over both pathological shapes (catastrophic backtracking is synchronous and would freeze the suite rather than fail one test), the bare-`/` class shapes verified to fail against the parent-commit blob, root-confinement tests covering the outside-file, outside-directory, cycle, broken-link and duplicate cases, direct isInsideRoot coverage including the sibling-prefix case that a bare startsWith would let through, sanitizeForReport coverage, and limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the exported constant. The earlier structural assertion was dropped — it checked for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats while staying exponential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): backfill changeset PR number Restores b77931869, which a force-push during the ReDoS remediation dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
746 lines
33 KiB
JavaScript
746 lines
33 KiB
JavaScript
/**
|
||
* 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
|
||
* - 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 { SCOPE } = require('../gsd-core/bin/lib/planning-scope.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);
|
||
});
|
||
|
||
// ─── #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(), '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);
|
||
});
|
||
|
||
// ─── #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(), 'gsd-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(), 'gsd-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);
|
||
});
|
||
|
||
test('#3183 row 7 regression: nested plans/PLAN-01.md ONLY is reported (used to report 0)', () => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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']);
|
||
});
|
||
});
|
||
|
||
// ─── 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);
|
||
});
|
||
});
|