Files
msd-core/tests/phase-id.test.cjs
Tom Boucher 2dedbdd11c fix(#1455): resolve project-code-prefixed roadmap headings
* fix: remove hardcoded phase project-code prefix cap

Centralize project-code prefix stripping/matching and replace fixed {1,6} caps so long codes (for example MANIFOLD-117) resolve across phase, roadmap parser, roadmap upgrade, and validate flows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address review feedback on prefix parsing

- allow project_code prefixes with digits and underscores while preserving milestone parsing
- use shared optional project-code prefix source in phase dir parsing
- extend regression coverage for APP1/APP_1 prefixed phases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: resolve review nit in phase-id test comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: resolve project-code-prefixed roadmap headings

Make getRoadmapPhaseInternal recover from drifted project-code-prefixed ROADMAP headings while preserving canonical bare-heading preference. Add init.phase-op and parser regressions for #1455 and guard gsd-roadmapper against emitting project_code in headings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: add changeset for project-code phase fix

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: update roadmapper agent size baseline

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(#1455): tighten project-code prefix regex to [A-Z] start; add boundary test; document source order

Resolves blockers from review:
- Regex changed from [A-Z_][A-Z0-9_]* to [A-Z][A-Z0-9_]* so leading
  underscores (_FOO-7, _-7) are never misread as project-code prefixes;
  adds boundary test asserting both do NOT strip.
- Adds comment to roadmapPhaseLookupSources explaining why 3 sources are
  needed (order-dependent canonical-heading preference).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:45:41 -04:00

432 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests for src/phase-id.cts (compiled to gsd-core/bin/lib/phase-id.cjs).
*
* Verifies behavioural contracts of the extracted pure phase-id helpers:
* - escapeRegex
* - normalizePhaseName
* - comparePhaseNum
* - extractPhaseToken
* - phaseTokenMatches
* - phaseMarkdownRegexSource
* - phaseMarkdownRegexSourceExact
* - getMilestoneFromPhaseId
* - getPhaseDirFromPhaseId
* - core.cjs re-export shims resolve to the exact same functions (single instance)
*
* ADR-857 rollout phase 2a / issue #865.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
// ─── escapeRegex ─────────────────────────────────────────────────────────────
describe('escapeRegex', () => {
test('escapes all regex special characters', () => {
assert.strictEqual(phaseId.escapeRegex('.'), '\\.');
assert.strictEqual(phaseId.escapeRegex('*'), '\\*');
assert.strictEqual(phaseId.escapeRegex('+'), '\\+');
assert.strictEqual(phaseId.escapeRegex('?'), '\\?');
assert.strictEqual(phaseId.escapeRegex('^'), '\\^');
assert.strictEqual(phaseId.escapeRegex('$'), '\\$');
assert.strictEqual(phaseId.escapeRegex('{'), '\\{');
assert.strictEqual(phaseId.escapeRegex('}'), '\\}');
assert.strictEqual(phaseId.escapeRegex('('), '\\(');
assert.strictEqual(phaseId.escapeRegex(')'), '\\)');
assert.strictEqual(phaseId.escapeRegex('|'), '\\|');
assert.strictEqual(phaseId.escapeRegex('['), '\\[');
assert.strictEqual(phaseId.escapeRegex(']'), '\\]');
assert.strictEqual(phaseId.escapeRegex('\\'), '\\\\');
});
test('leaves alphanumeric and hyphen characters unescaped', () => {
assert.strictEqual(phaseId.escapeRegex('abc'), 'abc');
assert.strictEqual(phaseId.escapeRegex('01-02'), '01-02');
assert.strictEqual(phaseId.escapeRegex('v1.0'), 'v1\\.0');
});
test('coerces non-string values via String()', () => {
assert.strictEqual(phaseId.escapeRegex(42), '42');
assert.strictEqual(phaseId.escapeRegex(null), 'null');
assert.strictEqual(phaseId.escapeRegex(undefined), 'undefined');
});
test('adversarial: path-traversal-like inputs are treated as literals', () => {
const result = phaseId.escapeRegex('../../../etc/passwd');
// The dots get escaped; slashes and alphanumeric pass through unchanged
assert.strictEqual(result, '\\.\\./\\.\\./\\.\\./etc/passwd');
// The result forms a valid regex (no throws)
assert.doesNotThrow(() => new RegExp(result));
});
test('unicode passthrough', () => {
assert.strictEqual(phaseId.escapeRegex('Phase Name'), 'Phase Name');
assert.strictEqual(phaseId.escapeRegex('中文'), '中文');
});
});
// ─── normalizePhaseName ───────────────────────────────────────────────────────
describe('normalizePhaseName', () => {
test('zero-pads single-digit phase', () => {
assert.strictEqual(phaseId.normalizePhaseName('1'), '01');
assert.strictEqual(phaseId.normalizePhaseName('3'), '03');
});
test('leaves two-digit phase unchanged', () => {
assert.strictEqual(phaseId.normalizePhaseName('12'), '12');
});
test('strips project_code prefix before normalizing', () => {
assert.strictEqual(phaseId.normalizePhaseName('CK-01'), '01');
assert.strictEqual(phaseId.normalizePhaseName('PROJ-3'), '03');
assert.strictEqual(phaseId.normalizePhaseName('AB-12'), '12');
assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-7'), '07');
assert.strictEqual(phaseId.normalizePhaseName('APP1-7'), '07');
assert.strictEqual(phaseId.normalizePhaseName('APP_1-7'), '07');
});
test('does not strip leading-underscore pseudo-prefix (#1455)', () => {
// Valid project_code values must start with [A-Z]; leading underscores
// (_FOO-7, _-7) are not valid codes and must not be stripped.
assert.strictEqual(phaseId.normalizePhaseName('_FOO-7'), '_FOO-7');
assert.strictEqual(phaseId.normalizePhaseName('_-7'), '_-7');
});
test('handles letter suffix (preserves original case per #1962)', () => {
assert.strictEqual(phaseId.normalizePhaseName('12A'), '12A');
assert.strictEqual(phaseId.normalizePhaseName('3b'), '03b');
});
test('handles decimal phase IDs', () => {
assert.strictEqual(phaseId.normalizePhaseName('12.1'), '12.1');
assert.strictEqual(phaseId.normalizePhaseName('3.10'), '03.10');
});
test('handles milestone-prefixed IDs (M-NN form)', () => {
assert.strictEqual(phaseId.normalizePhaseName('1-1'), '01-01');
assert.strictEqual(phaseId.normalizePhaseName('2-3'), '02-03');
assert.strictEqual(phaseId.normalizePhaseName('1-2-3'), '01-02-03');
});
test('custom phase IDs: project_code prefix is stripped, then numeric part is normalized', () => {
// The project-code prefix is stripped, leaving a numeric token that normalizes to '42' (no leading zero needed for 2+ digits).
assert.strictEqual(phaseId.normalizePhaseName('PROJ-42'), '42');
assert.strictEqual(phaseId.normalizePhaseName('AUTH-101'), '101');
assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-117'), '117');
});
test('custom phase IDs with non-numeric remainder pass through as-is', () => {
// No project_code pattern, no numeric match → return str as-is
assert.strictEqual(phaseId.normalizePhaseName('my-phase'), 'my-phase');
});
test('coerces non-string values', () => {
assert.strictEqual(phaseId.normalizePhaseName(5), '05');
});
});
// ─── comparePhaseNum ──────────────────────────────────────────────────────────
describe('comparePhaseNum', () => {
test('sorts numeric phases in ascending order', () => {
const phases = ['03', '01', '10', '02'];
const sorted = [...phases].sort(phaseId.comparePhaseNum);
assert.deepStrictEqual(sorted, ['01', '02', '03', '10']);
});
test('compares single-digit vs two-digit correctly', () => {
assert.ok(phaseId.comparePhaseNum('1', '02') < 0);
assert.ok(phaseId.comparePhaseNum('02', '1') > 0);
assert.strictEqual(phaseId.comparePhaseNum('1', '01'), 0);
});
test('handles decimal phases', () => {
assert.ok(phaseId.comparePhaseNum('1', '1.1') < 0);
assert.ok(phaseId.comparePhaseNum('1.1', '1.2') < 0);
assert.ok(phaseId.comparePhaseNum('1.10', '1.9') > 0);
assert.strictEqual(phaseId.comparePhaseNum('1.1', '01.1'), 0);
});
test('handles letter suffix ordering (no letter < A < B)', () => {
assert.ok(phaseId.comparePhaseNum('01', '01A') < 0);
assert.ok(phaseId.comparePhaseNum('01A', '01B') < 0);
assert.ok(phaseId.comparePhaseNum('01B', '01') > 0);
});
test('handles milestone-prefixed IDs', () => {
assert.ok(phaseId.comparePhaseNum('1-1', '1-2') < 0);
assert.ok(phaseId.comparePhaseNum('2-1', '1-10') > 0);
assert.ok(phaseId.comparePhaseNum('1-2-3', '1-2-4') < 0);
assert.strictEqual(phaseId.comparePhaseNum('01-01', '1-1'), 0);
});
test('strips project_code prefix before comparing', () => {
assert.strictEqual(phaseId.comparePhaseNum('CK-01', '01'), 0);
assert.ok(phaseId.comparePhaseNum('CK-01', 'CK-02') < 0);
assert.strictEqual(phaseId.comparePhaseNum('MANIFOLD-117', '117'), 0);
assert.strictEqual(phaseId.comparePhaseNum('APP1-117', '117'), 0);
assert.strictEqual(phaseId.comparePhaseNum('APP_1-117', '117'), 0);
});
test('handles non-parseable phase IDs via localeCompare fallback', () => {
// Should not throw on non-numeric IDs
const result = phaseId.comparePhaseNum('alpha', 'beta');
assert.strictEqual(typeof result, 'number');
});
});
// ─── extractPhaseToken ────────────────────────────────────────────────────────
describe('extractPhaseToken', () => {
test('extracts simple numeric token from directory name', () => {
assert.strictEqual(phaseId.extractPhaseToken('01-some-phase-name'), '01');
assert.strictEqual(phaseId.extractPhaseToken('12A-feature'), '12A');
});
test('extracts milestone-prefixed numeric token', () => {
assert.strictEqual(phaseId.extractPhaseToken('01-02-some-name'), '01-02');
assert.strictEqual(phaseId.extractPhaseToken('02-03-04-deep'), '02-03-04');
});
test('extracts token with project_code prefix', () => {
assert.strictEqual(phaseId.extractPhaseToken('CK-01-some-phase'), 'CK-01');
assert.strictEqual(phaseId.extractPhaseToken('PROJ-12-feature'), 'PROJ-12');
assert.strictEqual(phaseId.extractPhaseToken('MANIFOLD-117-feature'), 'MANIFOLD-117');
assert.strictEqual(phaseId.extractPhaseToken('APP1-117-feature'), 'APP1-117');
assert.strictEqual(phaseId.extractPhaseToken('APP_1-117-feature'), 'APP_1-117');
});
test('extracts glued letter-prefix phase tokens (#1324)', () => {
assert.strictEqual(phaseId.extractPhaseToken('P0.3-tenant-primitives'), 'P0.3');
assert.strictEqual(phaseId.extractPhaseToken('P0.0-foundation'), 'P0.0');
assert.strictEqual(phaseId.extractPhaseToken('P0.16-gate'), 'P0.16');
assert.strictEqual(phaseId.extractPhaseToken('M1-2-brain'), 'M1-2');
});
test('returns the full dirName when no numeric token found', () => {
assert.strictEqual(phaseId.extractPhaseToken('no-numeric'), 'no-numeric');
assert.strictEqual(phaseId.extractPhaseToken('alpha'), 'alpha');
assert.strictEqual(phaseId.extractPhaseToken('phase-name-01'), 'phase-name-01');
});
test('stops at first non-numeric-starting segment', () => {
assert.strictEqual(phaseId.extractPhaseToken('01-02-name-03'), '01-02');
});
});
// ─── phaseTokenMatches ────────────────────────────────────────────────────────
describe('phaseTokenMatches', () => {
test('matches exact token (case-insensitive)', () => {
assert.ok(phaseId.phaseTokenMatches('01-some-phase', '01'));
assert.ok(phaseId.phaseTokenMatches('12A-feature', '12A'));
assert.ok(phaseId.phaseTokenMatches('12A-feature', '12a'));
});
test('matches with project_code prefix stripped', () => {
assert.ok(phaseId.phaseTokenMatches('CK-01-phase', '01'));
assert.ok(phaseId.phaseTokenMatches('PROJ-12-feature', '12'));
assert.ok(phaseId.phaseTokenMatches('MANIFOLD-117-feature', '117'));
assert.ok(phaseId.phaseTokenMatches('APP1-117-feature', '117'));
assert.ok(phaseId.phaseTokenMatches('APP_1-117-feature', '117'));
});
test('matches glued letter-prefix phase dirs (#1324)', () => {
assert.ok(phaseId.phaseTokenMatches('P0.3-tenant-primitives', 'P0.3'));
assert.ok(phaseId.phaseTokenMatches('M1-2-brain', 'M1-2'));
assert.ok(!phaseId.phaseTokenMatches('P0.3-tenant-primitives', 'P0.4'));
});
test('does not match when token differs', () => {
assert.ok(!phaseId.phaseTokenMatches('01-some-phase', '02'));
assert.ok(!phaseId.phaseTokenMatches('12A-feature', '12B'));
});
test('matches milestone-prefixed token', () => {
assert.ok(phaseId.phaseTokenMatches('01-02-feature', '01-02'));
assert.ok(!phaseId.phaseTokenMatches('01-02-feature', '01-03'));
});
});
// ─── phaseMarkdownRegexSource ─────────────────────────────────────────────────
describe('phaseMarkdownRegexSource', () => {
test('produces a regex source that matches zero-padded variants', () => {
const src = phaseId.phaseMarkdownRegexSource('1');
const re = new RegExp(src);
assert.ok(re.test('1'));
assert.ok(re.test('01'));
assert.ok(re.test('001'));
});
test('produces source matching a two-digit phase', () => {
const src = phaseId.phaseMarkdownRegexSource('12');
const re = new RegExp(src);
assert.ok(re.test('12'));
assert.ok(re.test('012'));
assert.ok(!re.test('13'));
});
test('handles letter suffix', () => {
const src = phaseId.phaseMarkdownRegexSource('12A');
const re = new RegExp(src, 'i');
assert.ok(re.test('12A'));
assert.ok(re.test('012A'));
});
test('handles decimal phases', () => {
const src = phaseId.phaseMarkdownRegexSource('3.1');
const re = new RegExp(src);
assert.ok(re.test('3.1'));
assert.ok(re.test('03.1'));
assert.ok(!re.test('3.2'));
});
test('handles milestone-prefixed phase IDs', () => {
const src = phaseId.phaseMarkdownRegexSource('1-2');
const re = new RegExp(src);
assert.ok(re.test('1-2'));
assert.ok(re.test('01-02'));
assert.ok(re.test('01-2'));
assert.ok(!re.test('1-3'));
});
test('strips project_code prefix before building regex', () => {
const withPrefix = phaseId.phaseMarkdownRegexSource('CK-01');
const withoutPrefix = phaseId.phaseMarkdownRegexSource('01');
assert.strictEqual(withPrefix, withoutPrefix);
assert.strictEqual(phaseId.phaseMarkdownRegexSource('MANIFOLD-117'), phaseId.phaseMarkdownRegexSource('117'));
});
test('falls back to escaped literal for unparseable input', () => {
const src = phaseId.phaseMarkdownRegexSource('v1.0');
assert.strictEqual(typeof src, 'string');
assert.ok(src.length > 0);
});
test('adversarial: phase num containing regex metacharacters is escaped', () => {
// e.g. some exotic value that shouldn't break regexp construction
const src = phaseId.phaseMarkdownRegexSource('3.1');
// The literal dot in "3.1" should be escaped so it only matches a real dot
const re = new RegExp(src);
assert.ok(!re.test('3X1'), 'unescaped dot would match any char — must be escaped');
});
});
// ─── phaseMarkdownRegexSourceExact ────────────────────────────────────────────
describe('phaseMarkdownRegexSourceExact', () => {
test('returns escaped form for project-code-prefixed IDs', () => {
const result = phaseId.phaseMarkdownRegexSourceExact('PROJ-42');
// hyphen is not a regex special char so it passes through unescaped
assert.strictEqual(result, 'PROJ-42');
// The result is a valid regex source
assert.doesNotThrow(() => new RegExp(result));
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('MANIFOLD-117'), 'MANIFOLD-117');
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP1-117'), 'APP1-117');
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP_1-117'), 'APP_1-117');
});
test('returns null for non-prefixed IDs', () => {
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('01'), null);
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('12A'), null);
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('1-2'), null);
});
test('null coercion: returns null for null/undefined', () => {
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(null), null);
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(undefined), null);
});
test('resulting regex matches the exact prefixed ID', () => {
const src = phaseId.phaseMarkdownRegexSourceExact('AUTH-101');
assert.ok(src !== null);
const re = new RegExp(src);
assert.ok(re.test('AUTH-101'));
assert.ok(!re.test('AUTH-102'));
});
});
// ─── getMilestoneFromPhaseId ──────────────────────────────────────────────────
describe('getMilestoneFromPhaseId', () => {
test('returns vN.0 for a milestone-prefixed phase id', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('1-01'), 'v1.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('02-03'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('10-5'), 'v10.0');
});
test('returns null for non-milestone-prefixed IDs', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('01'), null);
assert.strictEqual(phaseId.getMilestoneFromPhaseId('12A'), null);
});
test('returns null for special sentinel milestones 0 and 999', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('0-1'), null);
assert.strictEqual(phaseId.getMilestoneFromPhaseId('999-1'), null);
});
test('strips project_code prefix before parsing', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('CK-2-01'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('MANIFOLD-2-01'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP1-2-01'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP_1-2-01'), 'v2.0');
});
test('coerces non-string values', () => {
// numeric doesn't match the milestone pattern — returns null
assert.strictEqual(phaseId.getMilestoneFromPhaseId(42), null);
});
});
// ─── getPhaseDirFromPhaseId ───────────────────────────────────────────────────
describe('getPhaseDirFromPhaseId', () => {
test('returns null for non-milestone-format IDs', () => {
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('01', null, null), null);
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('12A', null, null), null);
});
test('constructs dir name from milestone-prefixed phase id (no name, no code)', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-2', null, null);
assert.strictEqual(result, '01-02');
});
test('includes phaseName slug', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-2', 'My Feature', null);
assert.strictEqual(result, '01-02-my-feature');
});
test('prepends projectCode when provided', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-2', 'Auth', 'CK');
assert.strictEqual(result, 'CK-01-02-auth');
});
test('strips project_code from phaseId before parsing', () => {
const result = phaseId.getPhaseDirFromPhaseId('CK-1-2', null, null);
assert.strictEqual(result, '01-02');
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('MANIFOLD-1-2', null, null), '01-02');
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP1-1-2', null, null), '01-02');
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP_1-1-2', null, null), '01-02');
});
test('handles deep decomposition IDs (M-N-N)', () => {
// m[2] is "02-03" for input "1-2-3" — split and pad each sub-part
const result = phaseId.getPhaseDirFromPhaseId('1-2-3', null, null);
assert.strictEqual(result, '01-02-03');
});
test('slug strips leading/trailing hyphens from phaseName', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-1', ' --some--name-- ', null);
// normalize: replace non-alnum runs with hyphen, strip edges
assert.ok(result !== null);
assert.ok(!result.startsWith('-'));
assert.ok(!result.endsWith('-'));
});
});