* feat(#3907): gates report no-input instead of asserting a verdict they never reached The three stdin-reading gates bound 2 to a stdin read error only, with no arm for stdin closed at zero bytes - so empty input flowed into the detector, found nothing, and exited 1, which each module's own comment defines as a negative verdict. An unset PHASE_SECTION made the UI gate assert the phase has no UI. Empty and whitespace-only input now exit NO_INPUT, and a read error exits UNAVAILABLE rather than a locally-invented 2, both resolved through the registry and delivered by terminateNow. The exit code was only half of it: under --json the same input emitted {detected:false}, byte-identical to the fabricated payload #3909 exists to fix, and the blocking coverage gate reads that payload. Empty input now emits the in-tree {skipped:true,reason} form with no detected key at all. teams-status is excluded: it never reads stdin and has no invented 2, so the four-module framing in the issue and ADR is wrong. The dead root bin/lib/ui-safety-gate.cjs is deleted - no installer reference, no workflow invocation, and the live fallback chains are for other modules. Its removal restores the unit tests to the module that actually ships; they had been asserting the stale copy's two-field shape, which is why it drifted unnoticed. * fix(#3907): drive gate tests through the process seam, and make removed-but-needed basename-precise CONTRIBUTING requires every subprocess go through tests/helpers/process-seam.cjs; two of the three gate suites hand-rolled spawnSync while the third, added in the same change, used runNode correctly for the identical injection case. Converted the blocks this change added, leaving pre-existing ones alone. Deleting one of two files sharing a basename made lint-removed-but-needed report 14 references that were all to the surviving canonical module - the false-positive class its own docstring names. It now matches on the deleted file's full path when a surviving file shares its basename, which is more precise rather than weaker: a genuine full-path reference still fails, and behaviour is unchanged when no basename collides. It immediately caught a docstring on this branch that spelled the deleted path. * test(#3907): update the one existing assertion that pinned the old empty-stdin verdict A pre-existing test asserted exit 1 on empty stdin - the defect this phase removes - and was missed because the change added new blocks without auditing existing ones pinning the old contract. Audited the rest: the other three status-1 assertions in that file all feed real input and are the genuine-negative controls that must keep returning 1, so exactly one was stale. The retired 2 is gone from the describe's contract comment too. * chore(#3907): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
5.8 KiB
API Coverage Decision Checkpoint
Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase integrates an external API / SDK / service. Most non-API phases will not fire it — that is the point.
Why this exists
"We integrated the API" too often silently means "we integrated whatever the first use case exercised." Every un-built capability is then an invisible hole, discovered later by a user who reasonably expected it to work. The phase sealed green because its tasks completed; nobody decided the gaps were acceptable, because nobody enumerated them. This checkpoint makes the surface visible and decided before the phase can seal.
Detect whether this phase integrates an external API
The detector is a deterministic scan over the phase scope. It strips fenced
code blocks first, so a trigger term inside a code snippet does not fire. It
returns a typed result: { detected, signals[], terms }. Run it on the phase
scope (the concatenation of this phase's ROADMAP section + the PLAN body):
SCOPE="$(cat "${PHASE_DIR}"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase "${PHASE}" 2>/dev/null || true)"
API_COVERAGE_JSON=$(printf '%s' "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null || echo '{"detected":false,"signals":[]}')
The detector's exit code and --json payload now distinguish a real negative
from an unexamined input (ADR-3889 Phase 3, #3907): empty/whitespace-only
$SCOPE or a stdin read failure emit {"skipped":true,"reason":"no_input"| "stdin_error"} — no detected key at all. Check for skipped before
reading detected: a skipped payload is not a confirmed "no API
integration" verdict, it means the detector never examined real input. Do not
treat it as detected:false. Read API_COVERAGE_JSON.detected only when
skipped is absent — act on it only, do not pattern-match the prose
yourself.
If skipped is true: the detector could not establish a scope (empty
$SCOPE) or failed to run (stdin read error). Skip the checkpoint for this
run rather than asserting a verdict about input that was never examined; do
not raise it with the user.
If detected is false: this phase does not integrate an external API. Skip
the checkpoint entirely and continue planning. Do not raise it with the user.
If detected is true: an external-API integration is in scope. You MUST
produce a coverage matrix before the plan is finalized.
If detected is true but the phase genuinely integrates no external API
(the detector is deterministic, not infallible — confirm by re-reading the phase
scope, not by preference): do NOT fabricate a matrix row for a capability that
does not exist. Write a reasoned declaration to ${PHASE_DIR}/COVERAGE.md
instead:
No external API integration: <one-line reason — what the phase touches instead>.
The reason is required, exactly like an OPT-OUT reason. The seal-time gate
accepts this declaration in place of a matrix.
Produce the coverage matrix
Enumerate the external API's full capability surface — the verb/endpoint/method
list (e.g. for a music service: search, play, pause, skip, set_volume,
get_playlist, create_playlist, add_to_playlist, …). For each capability
record a decision, starting from full coverage as the default:
| capability | decision | reason |
|---|---|---|
<capability-id> |
INTEGRATE | OPT-OUT |
<one-line reason if OPT-OUT> |
Rules:
INTEGRATEis the default. Every capability starts as INTEGRATE; the matrix is the subtraction record.- Every
OPT-OUTMUST carry a one-line reason (not needed,not needed yet,explicitly out of scope, …). An opt-out without a reason is an un-decided hole — the exact failure mode this gate exists to close. - A second integration against the same need (e.g. a second platform for the same capability) starts from the same full-coverage baseline as the first. Do not carry over the first integration's opt-outs silently — re-decide each capability for the new surface, so a first-class/fallback asymmetry cannot accumulate.
Write the matrix to ${PHASE_DIR}/COVERAGE.md (canonical markdown-table form):
# API Coverage — <service>
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
| capability | decision | reason |
|---|---|---|
| search | INTEGRATE | |
| playlists | INTEGRATE | |
| skip | OPT-OUT | not needed yet — tracked for follow-up phase |
A fenced ```coverage JSON block is also accepted for machine-generated
matrices; the markdown table is preferred (human-editable, diff-friendly).
The seal-time gate
This checkpoint is enforced. At verify:pre the api-coverage.verify-pre gate
runs check api-coverage.verify-pre <phase-dir>:
- If
COVERAGE.mdexists, it is validated — every row needs a valid decision and everyOPT-OUTa reason. A malformed/partial matrix blocks the seal. A reasonedNo external API integration: …declaration (and no rows) passes. - If
COVERAGE.mdis absent, the detector runs again over the phase scope. If a strong external-API-integration signal is found, the seal is blocked until a matrix is produced. If no signal is found, the phase is treated as a non-API phase and the seal proceeds.
So: an API-integrating phase cannot seal without a decided matrix. Produce it at plan time; do not leave it for seal time.
Tuning the vocabulary (optional)
The trigger vocabulary is a curated, additive-only set in
gsd-core/bin/lib/api-coverage.cjs (DEFAULT_API_COVERAGE_TERMS). To widen it
for a project, override at the call site:
printf '%s' "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json \
--verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin
The whole checkpoint is toggleable via workflow.api_coverage_gate in
.planning/config.json.