fix(#4656): expose coverage.unclassified and widen the zero-applicable guards (#4800)

* fix(#4656): expose coverage.unclassified and widen the zero-applicable guards

* fix(#4656): regenerate golden coverage fixtures and update the rollup pin

Emitted-Drift-Ack-Growth: spec-phase.md — #4656: guard widened to the all-unclassified case, doc claim corrected
Emitted-Drift-Ack-Growth: ui-phase.md — #4656: guard widened identically

* fix(#4656): sync edge-probe doc blocks and coverage pins with the new field

* fix(#4656): key the mandatory confirmation on the widened guard

* docs(#4656): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-16 10:33:32 -04:00
committed by GitHub
parent caecaec62e
commit 3014775a3f
14 changed files with 515 additions and 53 deletions

View File

@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -69,6 +69,11 @@ export interface CoverageReport<V extends string = string> {
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<string, number>;
};
}
@@ -281,6 +286,12 @@ export function analyzeCoverage<V extends string>(
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<string, number> = {};
for (const tier of validators.verification) byVerification[tier] = 0;
for (const i of merged) {
@@ -288,7 +299,7 @@ export function analyzeCoverage<V extends string>(
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 } };
}
/* ------------------------------------------------------------------------- *

View File

@@ -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)',
);
});
});

View File

@@ -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');
});

View File

@@ -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);
});
});