fix(#2736): write current_phase_name from the transition intent, not the lossy prose round-trip (#2821)
* fix(#2736): intent-first current_phase_name on transitions; dash-first prose precedence Primary: completePhase (adapter) and beginPhase (via readModifyWriteStateMd options) pass the intent-held display name to syncStateFrontmatter as an authoritative override, applied after every derive/preserve/carry-forward step — so the lossy prose round-trip can never destroy a name the transition just resolved. Names containing a parenthetical (`Closer-ruling measurement (D1a)`) now land in frontmatter verbatim instead of collapsing to the parenthetical (`D1a`). Secondary (#1695 AC #3 residual): parsePhaseFromProse prefers the em-dash name when it is a genuine name (not a status keyword, not a `Milestone:` tail), else falls back to the parenthetical — satisfying both first-party writer shapes (`N — Name (aside)` and `N (Name) — EXECUTING`). Still lossy for paren-containing names, which is why the intent-first override is the primary fix. plannedPhase carries no name in its intent, so it is naturally out of scope. Fixes #2736 * docs(changeset): backfill PR number for #2736 fragment * fix(#2736): drop an unnecessary type assertion on result.data StateTransitionResult.data is already `Record<string, unknown> | undefined`, so the cast was a no-op and tripped @typescript-eslint/no-unnecessary-type- assertion (CI lint-tests red on the first push; every test lane was green). --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -538,12 +538,56 @@ describe('parsePhaseFromProse', () => {
|
||||
assert.equal(phaseId.parsePhaseFromProse('Phase 3A — Delta').phase, '3A');
|
||||
});
|
||||
|
||||
test('a status-word parenthetical is filtered from the name (preserved behavior)', () => {
|
||||
// parenName wins over the em-dash tail; "executing" is a status word → name null.
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta (executing)'), { phase: '3A', name: null });
|
||||
test('a status-word parenthetical is filtered from the name', () => {
|
||||
// #2736 precedence change (the #1695 AC #3 residual): the em-dash name now
|
||||
// wins when it is a genuine name, so `3A — Delta (executing)` yields
|
||||
// 'Delta' (previously null — paren-priority harvested the status aside and
|
||||
// the status filter nulled it, losing the real name).
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta (executing)'), { phase: '3A', name: 'Delta' });
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (complete)').name, null);
|
||||
});
|
||||
|
||||
test('#2736: status-keyword-aware precedence across the first-party writer shapes', () => {
|
||||
// completePhaseCore shape `N — Name (aside)`: the dash name wins; the
|
||||
// name's own parenthetical is no longer harvested as the whole name.
|
||||
assert.deepEqual(
|
||||
phaseId.parsePhaseFromProse('48 — Closer-ruling measurement (D1a)'),
|
||||
{ phase: '48', name: 'Closer-ruling measurement' },
|
||||
);
|
||||
// beginPhaseCore shape `N (Name) — EXECUTING`: the dash tail is a status
|
||||
// keyword, so the parenthetical name still wins.
|
||||
assert.deepEqual(
|
||||
phaseId.parsePhaseFromProse('16 (Native Global Hotkey) — EXECUTING'),
|
||||
{ phase: '16', name: 'Native Global Hotkey' },
|
||||
);
|
||||
// `N — COMPLETE` (state.cts phase-complete body line): status keyword on
|
||||
// the dash, no paren → no name.
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse('5 — COMPLETE'), { phase: '5', name: null });
|
||||
// gsd2-import shape `N (slug) — Milestone: Title`: the dash tail is a
|
||||
// milestone label, not a name → the parenthetical still wins.
|
||||
assert.deepEqual(
|
||||
phaseId.parsePhaseFromProse('06 (setup) — Milestone: Foundation'),
|
||||
{ phase: '06', name: 'setup' },
|
||||
);
|
||||
// Cross-AI review round 1: an em-dash INSIDE a parenthetical name must not
|
||||
// be mistaken for the name separator (the dash search runs on a
|
||||
// paren-stripped copy).
|
||||
assert.deepEqual(
|
||||
phaseId.parsePhaseFromProse('16 (Native — Global Hotkey) — EXECUTING'),
|
||||
{ phase: '16', name: 'Native — Global Hotkey' },
|
||||
);
|
||||
// Cross-AI review round 1: status-LIKE dash tails beyond the canonical
|
||||
// three lose to a parenthetical name (broader precedence vocabulary +
|
||||
// the lone-ALL-CAPS-token heuristic), without changing which extracted
|
||||
// names are nulled.
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (Foundation) — COMPLETED').name, 'Foundation');
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (Name) — In progress').name, 'Name');
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (Name) — READY').name, 'Name');
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (Name) — WIP').name, 'Name');
|
||||
// With no parenthetical to prefer, an unknown dash tail stays the best guess.
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 — WIP').name, 'WIP');
|
||||
});
|
||||
|
||||
test('#2124 review: name quantifiers are length-bounded (ReDoS guard)', () => {
|
||||
// A parenthetical within the bound extracts; one longer than the bound is
|
||||
// NOT matched — the cap is what prevents O(n^2) backtracking on a crafted
|
||||
|
||||
Reference in New Issue
Block a user