fix(#3189): drop non-id prose from phase_req_ids before gap check (#3438)

* fix(#3189): drop non-id prose from phase_req_ids before gap check

* chore(#3189): pin changeset fragment to pr 3438

* fix(#3189): use hyphenated IDs in e2e fixtures (parseRequirements requires hyphen)

* fix(#3189): assert e2e item set via table, not rows (check query has no rows)

* fix(#3189): check prose fragments against table rows, not the table heading

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 01:10:48 -04:00
committed by GitHub
parent dbbcb8f736
commit 8ecbac0350
4 changed files with 412 additions and 46 deletions

View File

@@ -194,6 +194,39 @@ const PHASE_REQ_RANGE_RE = /^(.+-)(\d+)\.\.(.+-)(\d+)$/;
*/
const MAX_PHASE_REQ_RANGE = 1000;
/**
* Shape filter applied to `--phase-req-ids` tokens AFTER range expansion
* (#3189). ROADMAP `**Requirements:**` lines routinely carry prose trailing
* the real ID list — locked-decision annotations, ambiguity scores,
* prohibitions, dates — and the function's own contract says callers may pass
* that roadmap value through verbatim. Without a shape filter every prose word
* survives the whitespace split and is reported as an individually-missing
* requirement, drowning the real coverage signal.
*
* The filter is intentionally wider than `ID_PATTERN` (which mandates a hyphen
* and so would reject the hyphen-less `R1`..`R8` family real roadmaps use).
* Three load-bearing details (#3189):
*
* 1. The digit lookahead `(?=.*\d)` matters. Without it ALL-CAPS prose tokens
* that appear in annotations (`LOCKED`, `TBD`-as-prose, `NONE`-as-prose)
* would pass and still reach the report as fake IDs. Every real
* requirement ID carries a digit (`R1`, `SEL-01`, `P1-P3`).
* 2. It MUST run after `expandPhaseReqIdToken`. Filtering before would
* discard the range tokens themselves (`SEL-01..SEL-03` matches no
* single-ID shape), silently undoing the #1269 / #1419 range expansion.
* 3. It is NOT reusable as `ID_PATTERN`. `ID_PATTERN` mandates a hyphen,
* so it rejects `R1`..`R8` and would leave only range-shaped tokens
* standing — a silently-empty report, which is worse.
*
* Accepts both hyphen-less digit-bearing IDs (`R1`, `R8`) and prefix-hyphen
* IDs (`REQ-01`, `SEL-01`, `BACK-07`), including multi-segment prefixes
* (`REQ2-01`). Rejects prose (`LOCKED`), punctuation (`—`, `##`, `+`),
* dates, version-likes (`0.12;`), backtick fragments, and any token still
* containing `.` (so invalid range tokens returned literal by
* `expandPhaseReqIdToken` are dropped rather than surfaced as fake IDs).
*/
const PHASE_REQ_ID_SHAPE_RE = /^(?=.*\d)[A-Z][A-Z0-9]*(?:[-_][A-Za-z0-9]+)*$/;
/**
* Expand a single `--phase-req-ids` token in place. If it is a valid ascending
* same-prefix numeric range (`<PREFIX>-NN..<PREFIX>-MM`, identical prefix both
@@ -250,6 +283,15 @@ function expandPhaseReqIdToken(token: string): string[] {
* spanning more than MAX_PHASE_REQ_RANGE IDs) stays literal — no partial
* expansion, no guessing.
*
* ID-shape filter (#3189): STRICTLY AFTER range expansion, every token is
* filtered through `PHASE_REQ_ID_SHAPE_RE`. ROADMAP `**Requirements:**` lines
* routinely carry prose trailing the real ID list (locked-decision annotations,
* ambiguity scores, prohibitions, dates); without the filter every prose word
* would be reported as an individually-missing requirement. Tokens that cannot
* be requirement IDs (prose, punctuation, dates, invalid range tokens left
* literal by `expandPhaseReqIdToken`) are dropped; an input whose every token
* is dropped collapses to `null` (skip semantics).
*
* Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
* the roadmap value through verbatim.
*/
@@ -264,7 +306,12 @@ function normalizePhaseReqIds(rawVal: unknown): string[] | null | undefined {
const ids = v.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
// Expand range tokens (#1269) per-token AFTER the split, preserving input order.
const expanded = ids.flatMap(expandPhaseReqIdToken);
return expanded.length === 0 ? null : expanded;
// #3189: drop tokens that cannot be requirement IDs (prose, punctuation,
// dates, invalid range tokens left literal by expandPhaseReqIdToken). MUST
// run after expand so valid ranges still expand (filtering before would
// discard `SEL-01..SEL-03` itself, undoing #1269 / #1419).
const filtered = expanded.filter(t => PHASE_REQ_ID_SHAPE_RE.test(t));
return filtered.length === 0 ? null : filtered;
}
function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOptions = {}): GapResult {