diff --git a/.changeset/lucky-jaguars-wake.md b/.changeset/lucky-jaguars-wake.md new file mode 100644 index 000000000..a25500416 --- /dev/null +++ b/.changeset/lucky-jaguars-wake.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4800 +--- +**The spec/ui zero-applicable guard now fires on the all-unclassified case** — probe coverage exposes an `unclassified` count beside `applicable`, and spec-phase/ui-phase warn when every requirement classifies to nothing instead of reporting a healthy non-zero total; `applicable` itself is count-preserving. (#4656) diff --git a/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json b/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json index ef3bf14e0..7d54f8ad8 100644 --- a/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +++ b/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json @@ -1,7 +1,32 @@ { "items": [ - { "requirement_id": "R1", "category": "boundary", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What happens exactly at each min/max/threshold — and one step either side?" }, - { "requirement_id": "R1", "category": "precision", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" } + { + "requirement_id": "R1", + "category": "boundary", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What happens exactly at each min/max/threshold — and one step either side?" + }, + { + "requirement_id": "R1", + "category": "precision", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" + } ], - "coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 2, + "resolved": 0, + "unresolved": 2, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } diff --git a/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json b/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json index b06b24b63..3aeea470d 100644 --- a/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +++ b/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json @@ -1,8 +1,41 @@ { "items": [ - { "requirement_id": "R1", "category": "adjacency", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" }, - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "ordering", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When elements compare equal, is output order specified and stable?" } + { + "requirement_id": "R1", + "category": "adjacency", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" + }, + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "ordering", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When elements compare equal, is output order specified and stable?" + } ], - "coverage": { "applicable": 3, "resolved": 0, "unresolved": 3, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 3, + "resolved": 0, + "unresolved": 3, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } diff --git a/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json b/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json index 19f3e07b3..96b3c5248 100644 --- a/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +++ b/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json @@ -1,7 +1,32 @@ { "items": [ - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "encoding", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?" } + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "encoding", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?" + } ], - "coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 2, + "resolved": 0, + "unresolved": 2, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } diff --git a/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json b/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json index ef3bf14e0..7d54f8ad8 100644 --- a/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +++ b/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json @@ -1,7 +1,32 @@ { "items": [ - { "requirement_id": "R1", "category": "boundary", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What happens exactly at each min/max/threshold — and one step either side?" }, - { "requirement_id": "R1", "category": "precision", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" } + { + "requirement_id": "R1", + "category": "boundary", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What happens exactly at each min/max/threshold — and one step either side?" + }, + { + "requirement_id": "R1", + "category": "precision", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" + } ], - "coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 2, + "resolved": 0, + "unresolved": 2, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } diff --git a/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json b/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json index b06b24b63..3aeea470d 100644 --- a/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +++ b/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json @@ -1,8 +1,41 @@ { "items": [ - { "requirement_id": "R1", "category": "adjacency", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" }, - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "ordering", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When elements compare equal, is output order specified and stable?" } + { + "requirement_id": "R1", + "category": "adjacency", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" + }, + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "ordering", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When elements compare equal, is output order specified and stable?" + } ], - "coverage": { "applicable": 3, "resolved": 0, "unresolved": 3, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 3, + "resolved": 0, + "unresolved": 3, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } diff --git a/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json b/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json index 4323f87ad..68d46da59 100644 --- a/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +++ b/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json @@ -1,8 +1,41 @@ { "items": [ - { "requirement_id": "R1", "category": "adjacency", "status": "resolved", "verification": "explicit", "resolution": "AC#6: touching intervals merge", "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" }, - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "ordering", "status": "dismissed", "verification": null, "resolution": null, "reason": "output is canonically sorted; no tie possible", "probe": "When elements compare equal, is output order specified and stable?" } + { + "requirement_id": "R1", + "category": "adjacency", + "status": "resolved", + "verification": "explicit", + "resolution": "AC#6: touching intervals merge", + "reason": null, + "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" + }, + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "ordering", + "status": "dismissed", + "verification": null, + "resolution": null, + "reason": "output is canonically sorted; no tie possible", + "probe": "When elements compare equal, is output order specified and stable?" + } ], - "coverage": { "applicable": 3, "resolved": 2, "unresolved": 1, "byVerification": { "explicit": 1, "backstop": 0 } } + "coverage": { + "applicable": 3, + "resolved": 2, + "unresolved": 1, + "unclassified": 0, + "byVerification": { + "explicit": 1, + "backstop": 0 + } + } } diff --git a/gsd-core/references/edge-probe.md b/gsd-core/references/edge-probe.md index 2844b22a9..ab6c2bf20 100644 --- a/gsd-core/references/edge-probe.md +++ b/gsd-core/references/edge-probe.md @@ -169,11 +169,44 @@ resolutions supplied, every applicable edge is `unresolved`: ```json edge-probe:02-merge-intervals/expected-coverage.json { "items": [ - { "requirement_id": "R1", "category": "adjacency", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" }, - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "ordering", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When elements compare equal, is output order specified and stable?" } + { + "requirement_id": "R1", + "category": "adjacency", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" + }, + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "ordering", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When elements compare equal, is output order specified and stable?" + } ], - "coverage": { "applicable": 3, "resolved": 0, "unresolved": 3, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 3, + "resolved": 0, + "unresolved": 3, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } ``` @@ -192,10 +225,35 @@ rule, the requirement classifies as `numeric-range`, which raises `boundary` and ```json edge-probe:01-round-half-even/expected-coverage.json { "items": [ - { "requirement_id": "R1", "category": "boundary", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What happens exactly at each min/max/threshold — and one step either side?" }, - { "requirement_id": "R1", "category": "precision", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" } + { + "requirement_id": "R1", + "category": "boundary", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What happens exactly at each min/max/threshold — and one step either side?" + }, + { + "requirement_id": "R1", + "category": "precision", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" + } ], - "coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 2, + "resolved": 0, + "unresolved": 2, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } ``` @@ -211,10 +269,35 @@ the requirement classifies as `text`, which raises `empty` and `encoding`: ```json edge-probe:03-truncate-graphemes/expected-coverage.json { "items": [ - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "encoding", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?" } + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "encoding", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?" + } ], - "coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 2, + "resolved": 0, + "unresolved": 2, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } ``` @@ -231,10 +314,35 @@ classifies as `numeric-range`, which raises `boundary` and `precision`: ```json edge-probe:04-money-rounding/expected-coverage.json { "items": [ - { "requirement_id": "R1", "category": "boundary", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What happens exactly at each min/max/threshold — and one step either side?" }, - { "requirement_id": "R1", "category": "precision", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" } + { + "requirement_id": "R1", + "category": "boundary", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What happens exactly at each min/max/threshold — and one step either side?" + }, + { + "requirement_id": "R1", + "category": "precision", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" + } ], - "coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 2, + "resolved": 0, + "unresolved": 2, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } ``` @@ -250,11 +358,44 @@ Given a requirement to deduplicate a list of items, the requirement classifies a ```json edge-probe:05-list-dedupe/expected-coverage.json { "items": [ - { "requirement_id": "R1", "category": "adjacency", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" }, - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "ordering", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When elements compare equal, is output order specified and stable?" } + { + "requirement_id": "R1", + "category": "adjacency", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" + }, + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "ordering", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "When elements compare equal, is output order specified and stable?" + } ], - "coverage": { "applicable": 3, "resolved": 0, "unresolved": 3, "byVerification": { "explicit": 0, "backstop": 0 } } + "coverage": { + "applicable": 3, + "resolved": 0, + "unresolved": 3, + "unclassified": 0, + "byVerification": { + "explicit": 0, + "backstop": 0 + } + } } ``` @@ -270,11 +411,44 @@ left unresolved: ```json edge-probe:06-resolved-mixed/expected-coverage.json { "items": [ - { "requirement_id": "R1", "category": "adjacency", "status": "resolved", "verification": "explicit", "resolution": "AC#6: touching intervals merge", "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" }, - { "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" }, - { "requirement_id": "R1", "category": "ordering", "status": "dismissed", "verification": null, "resolution": null, "reason": "output is canonically sorted; no tie possible", "probe": "When elements compare equal, is output order specified and stable?" } + { + "requirement_id": "R1", + "category": "adjacency", + "status": "resolved", + "verification": "explicit", + "resolution": "AC#6: touching intervals merge", + "reason": null, + "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" + }, + { + "requirement_id": "R1", + "category": "empty", + "status": "unresolved", + "verification": null, + "resolution": null, + "reason": null, + "probe": "What is the result for empty, single-element, or null input?" + }, + { + "requirement_id": "R1", + "category": "ordering", + "status": "dismissed", + "verification": null, + "resolution": null, + "reason": "output is canonically sorted; no tie possible", + "probe": "When elements compare equal, is output order specified and stable?" + } ], - "coverage": { "applicable": 3, "resolved": 2, "unresolved": 1, "byVerification": { "explicit": 1, "backstop": 0 } } + "coverage": { + "applicable": 3, + "resolved": 2, + "unresolved": 1, + "unclassified": 0, + "byVerification": { + "explicit": 1, + "backstop": 0 + } + } } ``` diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 3a5ecd29a..cf4773588 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -206,7 +206,10 @@ The SPEC keeps the original language — only `text_en` is translated, and requi Criteria you write from the resolved edges go into the SPEC in `response_language`). Populate `text_en` for **every** requirement, not only the ones that look edge-relevant: the `$APPLICABLE = 0` warning below fires only when *all* requirements are unclassified, so a -partly-classified spec slips through with no signal at all. When `response_language` is unset +partly-classified spec slips through with no signal at all. (#4656) The warning now ALSO +fires on the all-unclassified case itself: `coverage.unclassified` counts the soft-signal +rows, and the guard below fires when `$UNCLASSIFIED = $APPLICABLE` — the case where the +classifier learned nothing about ANY requirement. When `response_language` is unset (an English-language project), omit `text_en` — `text` is already English and the engine falls back to it automatically (`text_en ?? text`). If a requirement still classifies to zero shapes with `text_en` populated, it carries no cue in @@ -315,12 +318,15 @@ echo "### (end edge-probe coverage report)" # applicable:0. Surface it loudly; the author must explicitly confirm "no applicable edges" # below rather than silently emitting a green empty ## Edge Coverage section. APPLICABLE=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.applicable}catch{n=0}process.stdout.write(String(n))})') -if [ "$APPLICABLE" = "0" ]; then +UNCLASSIFIED=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.unclassified}catch{n=0}process.stdout.write(String(n))})') +# #4656: all-unclassified is the accidental-miss case the applicable:0 guard could never +# reach — #1110's soft-signal rows make applicable non-zero by construction there. +if [ "$APPLICABLE" = "0" ] || [ "$UNCLASSIFIED" = "$APPLICABLE" ]; then echo "WARNING: edge-probe proposed ZERO applicable edges across all requirements — likely a classification miss or malformed requirements, not a genuinely edge-free spec. Do NOT silently write an empty Edge Coverage section." >&2 fi ``` -If `$APPLICABLE` is `0`, do NOT proceed silently: ask the author to confirm via AskUserQuestion +If the guard above fired (`$APPLICABLE` is `0`, or every requirement is unclassified — `$UNCLASSIFIED = $APPLICABLE`, #4656), do NOT proceed silently: ask the author to confirm via AskUserQuestion ("The edge probe found no applicable edges for any requirement — is this genuinely an edge-free spec, or should we revisit the requirement wording / authored shapes?"). Only write an empty `## Edge Coverage` section after explicit confirmation. diff --git a/gsd-core/workflows/ui-phase.md b/gsd-core/workflows/ui-phase.md index b06c45eaa..1be560eb0 100644 --- a/gsd-core/workflows/ui-phase.md +++ b/gsd-core/workflows/ui-phase.md @@ -385,12 +385,14 @@ fi # Zero-applicable guard: a report where NO category applied across ANY element is far more likely a # classification miss (or malformed elements) than a genuinely state-free UI. Surface it loudly. APPLICABLE=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.applicable}catch{n=0}process.stdout.write(String(n))})') -if [ "$APPLICABLE" = "0" ]; then +UNCLASSIFIED=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.unclassified}catch{n=0}process.stdout.write(String(n))})') +# #4656: all-unclassified reads as a non-zero applicable — widen the guard to fire there too. +if [ "$APPLICABLE" = "0" ] || [ "$UNCLASSIFIED" = "$APPLICABLE" ]; then echo "WARNING: ui-consideration-probe proposed ZERO applicable categories across all elements — likely a classification miss or malformed elements, not a genuinely state-free UI. Do NOT silently write an empty UI Considerations section." >&2 fi ``` -If `$APPLICABLE` is `0`, do NOT proceed silently: ask via AskUserQuestion ("The UI probe found no +If the guard above fired (`$APPLICABLE` is `0`, or every element is unclassified — `$UNCLASSIFIED = $APPLICABLE`, #4656), do NOT proceed silently: ask via AskUserQuestion ("The UI probe found no applicable state considerations — is this genuinely a state-free surface, or should we revisit the element descriptions?"). Only write an empty section after explicit confirmation. diff --git a/src/probe-core.cts b/src/probe-core.cts index 6988f2f8a..e38fe580b 100644 --- a/src/probe-core.cts +++ b/src/probe-core.cts @@ -69,6 +69,11 @@ export interface CoverageReport { applicable: number; resolved: number; unresolved: number; + /** #4656: items in the soft-signal `unclassified` category. Subset of + * `applicable`; a spec where every requirement is unclassified reports + * `unclassified === applicable`, which is the signal the zero-applicable + * guards in spec-phase/ui-phase widen on. */ + unclassified: number; byVerification: Record; }; } @@ -281,6 +286,12 @@ export function analyzeCoverage( const unresolved = merged.filter((i) => i.status === 'unresolved').length; const applicable = merged.length; const resolved = applicable - unresolved; // closed set: resolved-status + dismissed + // #4656: the unclassified soft-signal rows count toward `applicable` (the + // rollup is count-preserving and `resolved = applicable - unresolved` is a + // documented identity), so the count is exposed as a SIBLING field — the + // zero-applicable guards can then also fire when EVERY requirement is + // unclassified, the case the spec-phase/ui-phase docs promise to catch. + const unclassified = merged.filter((i) => i.category === 'unclassified').length; const byVerification: Record = {}; for (const tier of validators.verification) byVerification[tier] = 0; for (const i of merged) { @@ -288,7 +299,7 @@ export function analyzeCoverage( byVerification[i.verification] = (byVerification[i.verification] ?? 0) + 1; } } - return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } }; + return { items: merged, coverage: { applicable, resolved, unresolved, unclassified, byVerification } }; } /* ------------------------------------------------------------------------- * diff --git a/tests/edge-probe-spec-phase-contract.test.cjs b/tests/edge-probe-spec-phase-contract.test.cjs index 5294d11a3..034d5ff01 100644 --- a/tests/edge-probe-spec-phase-contract.test.cjs +++ b/tests/edge-probe-spec-phase-contract.test.cjs @@ -10,7 +10,7 @@ process.env.GSD_TEST_MODE = '1'; -const { test } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); @@ -560,3 +560,37 @@ test('#3717: the edge-probe reference documents the text_en field', () => { 'the edge-probe reference `## Inputs` contract must document the optional text_en field and its text_en ?? text fallback', ); }); + +describe('#4656: the zero-applicable guard is widened to the all-unclassified case', () => { + // allow-test-rule: source-text-is-the-product (#4656) — spec-phase.md/ui-phase.md + // guard text is the shipped workflow contract. + const read = (f) => fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', f), 'utf-8'); + + test('spec-phase extracts coverage.unclassified and the guard fires on either condition', () => { + const content = read('spec-phase.md'); + assert.ok( + content.includes('coverage.unclassified'), + 'step 5.5 must extract the unclassified sibling count the #4656 rollup exposes', + ); + assert.ok( + content.includes('[ "$UNCLASSIFIED" = "$APPLICABLE" ]'), + 'the guard must fire when every requirement is unclassified (#4656), not only at applicable:0', + ); + assert.ok( + content.includes('If the guard above fired'), + 'the mandatory AskUserQuestion confirmation must key on the widened guard, not only on applicable:0 (#4656)', + ); + }); + + test('ui-phase carries the same widened guard', () => { + const content = read('ui-phase.md'); + assert.ok( + content.includes('coverage.unclassified') && content.includes('[ "$UNCLASSIFIED" = "$APPLICABLE" ]'), + 'ui-phase.md:385 guard must be widened identically (#4656)', + ); + assert.ok( + content.includes('If the guard above fired'), + 'ui-phase confirmation prose must key on the widened guard too (#4656)', + ); + }); +}); diff --git a/tests/edge-probe.test.cjs b/tests/edge-probe.test.cjs index 2a982c3fb..6e697f1fa 100644 --- a/tests/edge-probe.test.cjs +++ b/tests/edge-probe.test.cjs @@ -124,7 +124,7 @@ describe('edge-probe: analyzeCoverage', () => { const reqs = [{ id: 'R1', text: 'Merge a list of overlapping intervals' }]; test('with no resolutions, every applicable edge is unresolved (byVerification zeroed)', () => { const rep = ep.analyzeCoverage(reqs, []); - assert.deepEqual(rep.coverage, { applicable: 3, resolved: 0, unresolved: 3, byVerification: { explicit: 0, backstop: 0 } }); + assert.deepEqual(rep.coverage, { applicable: 3, resolved: 0, unresolved: 3, unclassified: 0, byVerification: { explicit: 0, backstop: 0 } }); }); test('merges a resolved/explicit resolution and counts it resolved', () => { const rep = ep.analyzeCoverage(reqs, [ @@ -153,7 +153,7 @@ describe('edge-probe: CLI (built artifact)', () => { const nodeResult = runNode([BUILT_SCRIPT, reqPath], { timeoutMs: PROBE_TIMEOUT_MS }); throwIfFailed(nodeResult, `node ${BUILT_SCRIPT} ${reqPath}`); const rep = JSON.parse(nodeResult.stdout); - assert.deepEqual(rep.coverage, { applicable: 2, resolved: 0, unresolved: 2, byVerification: { explicit: 0, backstop: 0 } }); + assert.deepEqual(rep.coverage, { applicable: 2, resolved: 0, unresolved: 2, unclassified: 0, byVerification: { explicit: 0, backstop: 0 } }); }); test('with no args exits with status 2 (assert on exit code, not stderr prose)', () => { const result = runNode([BUILT_SCRIPT], { timeoutMs: PROBE_TIMEOUT_MS }); @@ -194,7 +194,7 @@ describe('edge-probe: CLI JSON.parse error handling (RR-10)', () => { const r = runNode([BUILT_SCRIPT, reqPath], { timeoutMs: PROBE_TIMEOUT_MS }); assert.equal(r.exitCode, 0); const rep = JSON.parse(r.stdout); - assert.deepEqual(rep.coverage, { applicable: 2, resolved: 0, unresolved: 2, byVerification: { explicit: 0, backstop: 0 } }); + assert.deepEqual(rep.coverage, { applicable: 2, resolved: 0, unresolved: 2, unclassified: 0, byVerification: { explicit: 0, backstop: 0 } }); } finally { cleanup(dir); } @@ -255,6 +255,7 @@ describe('edge-probe: proposeEdges — unclassified candidate for prose-zero-cue const report = ep.analyzeCoverage([{ id: 'R1', text: 'Display the company logo' }]); assert.equal(report.coverage.applicable, 1); assert.equal(report.coverage.unresolved, 1); + assert.equal(report.coverage.unclassified, 1, '#4656: the unclassified sibling count must expose the soft-signal row'); assert.equal(report.items[0].category, 'unclassified'); }); diff --git a/tests/probe-core.test.cjs b/tests/probe-core.test.cjs index 1331af825..57a82bb58 100644 --- a/tests/probe-core.test.cjs +++ b/tests/probe-core.test.cjs @@ -137,7 +137,7 @@ describe('probe-core: analyzeCoverage (merge · rollup · byVerification)', () = test('no resolutions → every item unresolved; resolved 0; byVerification zeroed per tier', () => { const rep = pc.analyzeCoverage(UNRESOLVED_ITEMS, [], VALIDATORS); assert.deepEqual(rep.coverage, { - applicable: 3, resolved: 0, unresolved: 3, byVerification: { explicit: 0, backstop: 0 }, + applicable: 3, resolved: 0, unresolved: 3, unclassified: 0, byVerification: { explicit: 0, backstop: 0 }, }); }); test('merges a resolved/explicit resolution and counts byVerification.explicit', () => { @@ -688,3 +688,58 @@ describe('probe-core: projectTruths (#1154, conservative serializer — Postel) assert.equal(pc.truthVerification(parsed[1]), null, 'a plain truth round-trips with no marker (Hyrum byte-identity backward-compat)'); }); }); + +describe('probe-core: coverage.unclassified sibling count (#4656)', () => { + // #1110's soft-signal rows are legitimate rollup members, so `applicable` + // counts them and the documented zero-applicable guards could never fire on + // an all-unclassified spec. The fix exposes the count as a SIBLING field, + // leaving `applicable` and the documented `resolved = applicable - + // unresolved` identity count-preserving. + + const UNCLASSIFIED_VALIDATORS = { + categories: ['idempotency', 'ordering', 'unclassified'], + verification: ['explicit', 'backstop'], + requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] }, + }; + + test('the issue defect case: all requirements unclassified reports unclassified === applicable', () => { + const items = [ + item('unclassified', { requirement_id: 'CONN-01' }), + item('unclassified', { requirement_id: 'CONN-02' }), + item('unclassified', { requirement_id: 'PJL-01' }), + ]; + const rep = pc.analyzeCoverage(items, [], UNCLASSIFIED_VALIDATORS); + assert.equal(rep.coverage.applicable, 3); + assert.equal(rep.coverage.unclassified, 3, 'the all-unclassified case must be distinguishable from a spec with genuine edge surface'); + assert.equal(rep.coverage.resolved, 0); + assert.equal(rep.coverage.unresolved, 3); + }); + + test('a classified spec reports unclassified 0 (control 1 — text_en populated)', () => { + const items = [ + item('idempotency', { requirement_id: 'CONN-01' }), + item('ordering', { requirement_id: 'CONN-02' }), + ]; + const rep = pc.analyzeCoverage(items, [], UNCLASSIFIED_VALIDATORS); + assert.equal(rep.coverage.applicable, 2); + assert.equal(rep.coverage.unclassified, 0); + }); + + test('an edge-free requirement (no items) reports applicable 0 and unclassified 0 (control 2)', () => { + const rep = pc.analyzeCoverage([], [], UNCLASSIFIED_VALIDATORS); + assert.equal(rep.coverage.applicable, 0); + assert.equal(rep.coverage.unclassified, 0); + }); + + test('mixed spec: unclassified is a strict subset of applicable; the counting identity is preserved', () => { + const items = [ + item('idempotency', { requirement_id: 'CONN-01' }), + item('unclassified', { requirement_id: 'CONN-02' }), + ]; + const rep = pc.analyzeCoverage(items, [], UNCLASSIFIED_VALIDATORS); + assert.equal(rep.coverage.applicable, 2); + assert.equal(rep.coverage.unclassified, 1); + assert.ok(rep.coverage.unclassified <= rep.coverage.applicable); + assert.equal(rep.coverage.resolved, rep.coverage.applicable - rep.coverage.unresolved); + }); +});