From 6addeccd190b70b5f979bbca163c2ed8cc79c8df Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 7 Jul 2026 14:35:30 -0400 Subject: [PATCH] feat(ai-integration): API-coverage verify:pre gate (#1562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full API Coverage by Default — Opt Out, Never Opt In. A phase that integrates an external API/SDK/service can no longer seal without a decided coverage matrix. - src/api-coverage.cts: deterministic detector (compound verb+noun signal + API/SDK surface; stopword-guarded; strips fenced code) + matrix parse/validate/render with field-length caps. - check api-coverage.verify-pre: blocking seal-time gate; phase arg resolved as a token under .planning/phases/ only (traversal-neutralized); validates COVERAGE.md or blocks iff a strong integration signal is detected and no matrix exists; fail-closed when phases tree exists but phase unresolvable. - capabilities/ai-integration: workflow.api_coverage_gate config key (default true), plan:pre contribution, blocking verify:pre gate. Data-driven. - gsd-core/workflows/verify-work.md: generic verify:pre gate dispatch. - Tests: detector FP/FN + matrix validation + fast-check bijection; gate e2e. Code+security review findings fixed (stopword FP, scope containment, pipe/cap rejection, prompt-injection message hygiene). - Regenerated registry/matrix/loop-host-contract/goldens/baseline + docs. Closes #1562 --- .changeset/1562-api-coverage-gate.md | 5 + capabilities/ai-integration/capability.json | 35 +- .../fragments/api-coverage-plan-pre.md | 105 ++++ docs/CONFIGURATION.md | 1 + docs/INVENTORY-MANIFEST.json | 2 + docs/INVENTORY.md | 2 + docs/reference/capability-matrix.md | 2 +- eslint.config.mjs | 1 + gsd-core/bin/lib/api-coverage.cjs | 466 ++++++++++++++++ gsd-core/bin/lib/capability-registry.cjs | 73 ++- gsd-core/references/api-coverage.md | 104 ++++ gsd-core/references/planning-config.md | 1 + gsd-core/workflows/verify-work.md | 38 ++ src/api-coverage.cts | 514 ++++++++++++++++++ src/check-command-router.cts | 284 +++++++++- src/config-loader.cts | 1 + src/config.cts | 1 + tests/api-coverage-gate-e2e.test.cjs | 273 ++++++++++ tests/api-coverage.test.cjs | 410 ++++++++++++++ tests/config-field-docs.test.cjs | 1 + .../golden-install-parity/antigravity.json | 5 +- .../golden-install-parity/augment.json | 5 +- .../golden-install-parity/claude.json | 5 +- .../fixtures/golden-install-parity/cline.json | 5 +- .../golden-install-parity/codebuddy.json | 5 +- .../fixtures/golden-install-parity/codex.json | 5 +- .../golden-install-parity/copilot.json | 5 +- .../golden-install-parity/cursor.json | 5 +- .../golden-install-parity/hermes.json | 5 +- .../fixtures/golden-install-parity/kilo.json | 5 +- .../fixtures/golden-install-parity/kimi.json | 5 +- .../golden-install-parity/opencode.json | 5 +- .../fixtures/golden-install-parity/qwen.json | 5 +- .../fixtures/golden-install-parity/trae.json | 5 +- .../golden-install-parity/windsurf.json | 5 +- .../fixtures/golden-install-parity/zcode.json | 5 +- ...e-2045-third-party-skills-surface.test.cjs | 2 +- tests/loop-hooks-empty-points-e2e.test.cjs | 54 +- tests/plan-pre-hook-e2e.test.cjs | 1 + tests/workflow-size-baseline.json | 2 +- 40 files changed, 2404 insertions(+), 54 deletions(-) create mode 100644 .changeset/1562-api-coverage-gate.md create mode 100644 capabilities/ai-integration/fragments/api-coverage-plan-pre.md create mode 100644 gsd-core/bin/lib/api-coverage.cjs create mode 100644 gsd-core/references/api-coverage.md create mode 100644 src/api-coverage.cts create mode 100644 tests/api-coverage-gate-e2e.test.cjs create mode 100644 tests/api-coverage.test.cjs diff --git a/.changeset/1562-api-coverage-gate.md b/.changeset/1562-api-coverage-gate.md new file mode 100644 index 000000000..9abfb1f52 --- /dev/null +++ b/.changeset/1562-api-coverage-gate.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2065 +--- +**Phases that integrate an external API/SDK/service can no longer seal without a decided coverage matrix** — a new `api-coverage` gate on the `ai-integration` capability blocks `/gsd:verify-work` until the phase produces a `COVERAGE.md` enumerating the API's full capability surface, with every non-integrated capability an explicit, reasoned opt-out. Full coverage is the default; the matrix is the subtraction record, so "we integrated the API" can no longer silently mean "we integrated whatever the first use case exercised." Toggleable via `workflow.api_coverage_gate` (on by default). (#1562) diff --git a/capabilities/ai-integration/capability.json b/capabilities/ai-integration/capability.json index 998933a2d..8e5147267 100644 --- a/capabilities/ai-integration/capability.json +++ b/capabilities/ai-integration/capability.json @@ -30,6 +30,11 @@ "type": "boolean", "default": true, "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems." + }, + "workflow.api_coverage_gate": { + "type": "boolean", + "default": true, + "description": "Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre the planner is prompted to enumerate the API surface into COVERAGE.md; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out. Independent of ai_integration_phase (applies to any external-API integration, not only AI)." } }, "steps": [ @@ -48,6 +53,32 @@ "onError": "skip" } ], - "contributions": [], - "gates": [] + "contributions": [ + { + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/api-coverage-plan-pre.md" + }, + "produces": [ + "COVERAGE.md" + ], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.api_coverage_gate", + "onError": "skip" + } + ], + "gates": [ + { + "point": "verify:pre", + "check": { + "query": "api-coverage.verify-pre" + }, + "when": "workflow.api_coverage_gate", + "blocking": true, + "onError": "halt" + } + ] } diff --git a/capabilities/ai-integration/fragments/api-coverage-plan-pre.md b/capabilities/ai-integration/fragments/api-coverage-plan-pre.md new file mode 100644 index 000000000..ff280890a --- /dev/null +++ b/capabilities/ai-integration/fragments/api-coverage-plan-pre.md @@ -0,0 +1,105 @@ +# 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): + +```bash +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":[]}') +``` + +Read `API_COVERAGE_JSON.detected`. Act on it only — do **not** pattern-match the +prose yourself. + +**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. + +## 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 | +|---|---|---| +| `` | `INTEGRATE` \| `OPT-OUT` | `` | + +Rules: + +- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the + matrix is the *subtraction record*. +- **Every `OPT-OUT` MUST 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): + +```markdown +# API Coverage — + +> 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 `: + +- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and + every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**. +- If `COVERAGE.md` is 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: + +```bash +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`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f1c4357a8..d754a4887 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -325,6 +325,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.cross_ai_timeout` | number | `300` | Timeout in seconds for cross-AI execution commands. Prevents runaway external processes. Added in v1.36 | | `workflow.test_gate_timeout` | number | `600` | Wall-clock timeout (seconds) for a verification test gate; a watch-mode runner (vitest/jest) that never exits is aborted after this budget instead of hanging the orchestrator (#1857) | | `workflow.ai_integration_phase` | boolean | `true` | Enable the `/gsd-ai-integration-phase` command. When `false`, the command exits with a configuration gate message | +| `workflow.api_coverage_gate` | boolean | `true` | Require an explicit API-coverage decision before a phase that integrates an external API/SDK/service can seal. At `plan:pre` the planner is prompted to produce a `COVERAGE.md` matrix (full coverage by default, every opt-out reasoned); at `verify:pre` a blocking gate fails the seal unless the matrix is complete. Independent of `ai_integration_phase` (#1562) | | `workflow.auto_prune_state` | boolean | `false` | When `true`, automatically prune stale entries from STATE.md at phase boundaries instead of prompting | | `workflow.pattern_mapper` | boolean | `true` | Run the `gsd-pattern-mapper` agent between research and planning to map new files to existing codebase analogs | | `workflow.subagent_timeout` | number | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes) | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 8a5fd93fa..d3d251353 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -207,6 +207,7 @@ "agent-skills-bootstrap.md", "ai-evals.md", "ai-frameworks.md", + "api-coverage.md", "artifact-types.md", "autonomous-smart-discuss.md", "checkpoints.md", @@ -292,6 +293,7 @@ "adr-parser.cjs", "agent-command-router.cjs", "agent-install-check.cjs", + "api-coverage.cjs", "artifacts.cjs", "assumption-delta.cjs", "audit-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 8743b4305..5dfbe8688 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -331,6 +331,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum | `autonomous-smart-discuss.md` | Smart-discuss logic for autonomous mode. | | `ios-scaffold.md` | iOS application scaffolding patterns. | | `ai-evals.md` | AI evaluation design reference for `/gsd-ai-integration-phase`. | +| `api-coverage.md` | API-coverage gate reference (full-coverage-by-default) for the `ai-integration` capability's `verify:pre` blocking gate (#1562) — matrix format, trigger, tuning, detector CLI. | | `ai-frameworks.md` | AI framework decision-matrix reference for `gsd-framework-selector`. | | `executor-examples.md` | Worked examples for the gsd-executor agent. | | `doc-conflict-engine.md` | Shared conflict-detection contract for ingest/import workflows. | @@ -397,6 +398,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | +| `api-coverage.cjs` | API-coverage detector + matrix validator (#1562) — pure `detectApiIntegration` (compound verb+noun signal + ` API/SDK` surface; strips fenced code) and `validateCoverageMatrix`/`parseCoverageMatrix`/`renderCoverageMatrix` for the COVERAGE.md artifact; STDIN CLI (`echo "$SCOPE" \| node .../api-coverage.cjs [--json]`, exit 0=detected/1=none/2=error); consumed by the `ai-integration` capability's `plan:pre` contribution and blocking `verify:pre` gate (`check api-coverage.verify-pre`) | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index c74755eac..6cb34f4a9 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -52,7 +52,7 @@ points. | id | role | tier | engines.gsd | extension points | hook kinds | source | |---|---|---|---|---|---|---| -| `ai-integration` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party | +| `ai-integration` | feature | full | `>=1.6.0` | `plan:pre`, `verify:pre` | step, contribution, gate | first-party | | `assumption-delta` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party | | `audit` | feature | full | `>=1.6.0` | — | — | first-party | | `claude-orchestration` | feature | full | `>=1.7.0` | `plan:post`, `execute:wave:post` | contribution | first-party | diff --git a/eslint.config.mjs b/eslint.config.mjs index 493b9a219..148104840 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -80,6 +80,7 @@ export default tseslint.config( 'gsd-core/bin/lib/prohibition-enforcement.cjs', 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', + 'gsd-core/bin/lib/api-coverage.cjs', 'gsd-core/bin/lib/artifacts.cjs', 'gsd-core/bin/lib/assumption-delta.cjs', 'gsd-core/bin/lib/state-transition.cjs', diff --git a/gsd-core/bin/lib/api-coverage.cjs b/gsd-core/bin/lib/api-coverage.cjs new file mode 100644 index 000000000..c51f9b6d7 --- /dev/null +++ b/gsd-core/bin/lib/api-coverage.cjs @@ -0,0 +1,466 @@ +"use strict"; +/** + * API-Coverage detector + matrix validator (#1562). + * + * The enforcement half of "Full API Coverage by Default — Opt Out, Never Opt In." + * When a phase integrates an external API/service/SDK, the planner must produce a + * coverage matrix (COVERAGE.md) enumerating the API's capability surface; every + * non-integrated capability is an explicit, reasoned opt-out. The seal-time gate + * (capabilities/ai-integration, verify:pre) consumes this module to (a) detect + * whether a phase integrates an external API and (b) validate the produced matrix. + * + * Design notes (rubber-duck'd): + * - DETERMINISTIC + TYPED IR. Both the "does this phase integrate an external + * API?" decision and the "is this matrix complete?" decision are pure + * functions returning typed IR, not LLM judgments — so the low-false-positive + * guarantee (acceptance criterion #4) and the completeness guarantee + * (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561). + * - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in + * countless non-integration phases ("the public API of UserController"). The + * detector requires an INTEGRATION VERB co-occurring with an EXTERNAL-API + * NOUN (or an explicit " API/SDK" phrase). Single weak tokens do not + * fire. This is the issue's "low false-positive trigger" made mechanical. + * - FENCED CODE BLOCKS ARE STRIPPED first (markdown-sectionizer seam) so a + * trigger term inside a code snippet does not fire. + * - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution + * prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is + * ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision + * therefore matters but is not the only line of defense. + * - MATRIX FORMAT. The matrix is a markdown table (human-editable, diff-friendly) + * with a header row `| capability | decision | reason |` and one row per + * capability. decision ∈ {INTEGRATE, OPT-OUT}. An OPT-OUT row MUST carry a + * non-empty reason. A fenced ```coverage JSON block is also accepted for + * machine-generated matrices. This dual shape is bijective (parse/render + * round-trip) and covered by a fast-check property test. + * - ADDITIVE-ONLY VOCABULARY (Hyrum's Law). Once shipped, the verb/noun sets + * are depended-upon interfaces; they only grow. Tunable via the `terms` + * parameter so teams can widen them without forking. + * + * Public API: + * detectApiIntegration(text, terms?) -> { detected, signals, terms } + * parseCoverageMatrix(text) -> { rows, errors, format } + * validateCoverageMatrix(text) -> { valid, errors, counts } + * renderCoverageMatrix(rows) -> string + * DEFAULT_API_COVERAGE_TERMS + * + * CLI: + * echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json] + * exit 0 = integration detected, 1 = none, 2 = startup error + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_API_COVERAGE_TERMS = void 0; +exports.detectApiIntegration = detectApiIntegration; +exports.parseCoverageMatrix = parseCoverageMatrix; +exports.validateCoverageMatrix = validateCoverageMatrix; +exports.renderCoverageMatrix = renderCoverageMatrix; +const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs"); +/** + * Curated default trigger vocabulary. ADDITIVE-ONLY (Hyrum's Law). Tunable via + * the `terms` parameter. + * + * VERBS are deliberately conservative: common verbs like "add", "use", "call", + * "implement" are EXCLUDED because they appear in nearly every phase and would + * make the gate fire on prose that has nothing to do with an external API. The + * verbs kept all connote BRINGING IN an external surface. + * + * NOUNS name an external-API surface. Bare "client" is excluded — too ambiguous + * (client-side UI vs API client). "service" alone is excluded (internal + * services); a phase integrating an external service virtually always pairs it + * with "API"/"SDK"/"REST"/etc., which the compound verb+noun rule captures. + */ +exports.DEFAULT_API_COVERAGE_TERMS = { + verbs: [ + 'integrate', + 'integrates', + 'integrating', + 'integration', + 'wrap', + 'wraps', + 'wrapping', + 'connect', + 'connects', + 'connecting', + 'consume', + 'consumes', + 'consuming', + 'wire', + 'wires', + 'wiring', + 'onboard', + 'onboarding', + 'adopt', + 'adopts', + 'adopting', + ], + nouns: [ + 'api', + 'apis', + 'sdk', + 'sdks', + 'rest', + 'graphql', + 'grpc', + 'endpoint', + 'endpoints', + 'oauth', + 'oauth2', + 'webhook', + 'webhooks', + 'mcp', + ], +}; +/** Hardening caps for the tunable vocabulary (hostile `--terms` defense). */ +const MAX_TERMS_PER_KIND = 200; +const MAX_TERM_LEN = 32; +/** + * Field-length caps for matrix cell values. Cell content flows from a + * semi-trusted COVERAGE.md into the gate `message` that the orchestrator LLM + * reads, so it is bounded to keep the prompt-injection surface small and to + * document the format contract (short, single-line prose — not paragraphs). + */ +const CAPABILITY_MAX_LEN = 80; +const REASON_MAX_LEN = 200; +function normalizeTerms(list) { + if (!Array.isArray(list)) + return []; + const seen = new Set(); + const out = []; + for (const raw of list) { + if (typeof raw !== 'string') + continue; + const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); + if (!t || !/[a-z0-9]/.test(t)) + continue; + if (seen.has(t)) + continue; + seen.add(t); + out.push(t); + if (out.length >= MAX_TERMS_PER_KIND) + break; + } + return out; +} +function resolveTerms(terms) { + const merge = (key) => { + const t = terms && terms[key]; + return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_API_COVERAGE_TERMS[key]]; + }; + return { verbs: merge('verbs'), nouns: merge('nouns') }; +} +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +function makeSnippet(line, anchor) { + const cleaned = line.replace(/\s+/g, ' ').trim(); + if (cleaned.length <= 120) + return cleaned; + const idx = cleaned.toLowerCase().indexOf(anchor); + if (idx < 0) + return cleaned.slice(0, 120); + const start = Math.max(0, idx - 50); + const end = Math.min(cleaned.length, idx + anchor.length + 50); + const prefix = start > 0 ? '…' : ''; + const suffix = end < cleaned.length ? '…' : ''; + return `${prefix}${cleaned.slice(start, end)}${suffix}`; +} +/** ` API` / ` SDK` — a capitalized proper noun immediately + * followed by API/SDK. Strong signal on its own (no verb required). + * + * STOPWORDS guard against the false positive where an ordinary capitalized + * sentence starter ("The API …", "An SDK …", "Our REST …") matches the + * `[A-Z]\w+ API` shape. Those are common English, not a service name, so they + * are rejected before counting as a surface signal (acceptance #4 — low false + * positives). */ +const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,})\s+(API|SDK|REST|GraphQL)\b/; +const SERVICE_STOPWORDS = new Set([ + 'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add', + 'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both', + 'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their', + 'we', 'you', 'they', 'it', +]); +/** + * Detect whether phase-scope prose describes integrating an external API/SDK. + * + * Fires when EITHER: + * (a) a compound verb+noun signal co-occurs on the same line, OR + * (b) an explicit ` API|SDK|REST|GraphQL` surface appears. + * + * Non-string inputs degrade to `{ detected: false }` without throwing. + */ +function detectApiIntegration(text, terms) { + const effective = resolveTerms(terms); + if (typeof text !== 'string') { + return { detected: false, signals: [], terms: effective }; + } + const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text; + if (stripped.trim().length === 0) { + return { detected: false, signals: [], terms: effective }; + } + const signals = []; + const seen = new Set(); + const lines = stripped.split('\n'); + // (a) compound verb+noun on the same line. + if (effective.verbs.length > 0 && effective.nouns.length > 0) { + const verbRe = new RegExp('(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', 'gi'); + const nounRe = new RegExp('(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', 'gi'); + for (const line of lines) { + verbRe.lastIndex = 0; + nounRe.lastIndex = 0; + const vMatch = verbRe.exec(line); + if (!vMatch) + continue; + const nMatch = nounRe.exec(line); + if (!nMatch) + continue; + const verb = (vMatch[2] || '').toLowerCase(); + const noun = (nMatch[2] || '').toLowerCase(); + const key = `${verb}+${noun}`; + if (seen.has(key)) + continue; + seen.add(key); + signals.push({ verb, noun, snippet: makeSnippet(line, noun) }); + } + } + // (b) explicit API|SDK|REST|GraphQL surface. + for (const line of lines) { + SERVICE_SURFACE_API_RE.lastIndex = 0; + const m = SERVICE_SURFACE_API_RE.exec(line); + if (!m) + continue; + // Reject ordinary capitalized sentence starters ("The API …", "Our REST …"). + if (SERVICE_STOPWORDS.has((m[1] || '').toLowerCase())) + continue; + const noun = (m[2] || '').toLowerCase(); + const key = `surface+${noun}`; + if (seen.has(key)) + continue; + seen.add(key); + signals.push({ verb: '(surface)', noun, snippet: makeSnippet(line, m[1]) }); + } + return { detected: signals.length > 0, signals, terms: effective }; +} +const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']); +/** + * Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats: + * + * 1. Markdown table (canonical, human-editable): + * | capability | decision | reason | + * |---|---|---| + * | search | INTEGRATE | | + * | playlists | OPT-OUT | not needed yet | + * + * 2. Fenced ```coverage JSON block (machine-generated): + * ```coverage + * [ {"capability":"search","decision":"INTEGRATE","reason":""}, ... ] + * ``` + * + * Rows are trimmed; decisions upper-cased; missing reason → "". Returns + * `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input. + */ +function parseCoverageMatrix(text) { + const out = { rows: [], errors: [], format: 'none' }; + if (typeof text !== 'string') + return out; + const src = text.replace(/\r\n/g, '\n'); + // (1) fenced ```coverage JSON block takes precedence if present. + // Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark). + // allow-adhoc-markdown: extracting a NAMED ```coverage fence (extraction of one tagged block), not stripping all fences — stripFencedCode/extractTaggedBlocks do not cover named-fence extraction. + const fenceMatch = src.match(/```coverage\s*\n([\s\S]*?)\n```/i); + if (fenceMatch && fenceMatch[1]) { + out.format = 'json'; + let parsed; + try { + parsed = JSON.parse(fenceMatch[1]); + } + catch { + out.errors.push('fenced ```coverage block is not valid JSON'); + return out; + } + if (!Array.isArray(parsed)) { + out.errors.push('fenced ```coverage block must be a JSON array'); + return out; + } + for (let i = 0; i < parsed.length; i++) { + const row = rowFromJson(parsed[i]); + if ('error' in row) { + out.errors.push(`row[${i}]: ${row.error}`); + continue; + } + out.rows.push(row); + } + return out; + } + // (2) markdown table — collect table rows whose decision column parses. + const lines = src.split('\n'); + let sawHeader = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) + continue; + const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|'); + if (cells.length < 2) + continue; + const cleaned = cells.map((c) => c.trim()); + // skip separator rows (|---|---|); require ≥3 dashes so a literal "-" cell + // is not mistaken for a separator. + if (cleaned.every((c) => /^:?-{3,}:?$/.test(c))) + continue; + const decisionCell = (cleaned[1] || '').toUpperCase(); + // header detection + if (!sawHeader && cleaned[0].toLowerCase() === 'capability') { + sawHeader = true; + out.format = 'table'; + continue; + } + if (!VALID_DECISIONS.has(decisionCell)) { + // A row that otherwise looks like data (≥3 cells, non-empty capability) + // but carries a malformed decision is a real error, not a row to skip + // silently — otherwise a single typo'd row collapses the matrix to + // "empty" and the user sees a confusing message. + if (cleaned.length >= 3 && cleaned[0]) { + out.errors.push(`row: decision "${decisionCell}" not in {INTEGRATE, OPT-OUT}`); + } + continue; + } + if (out.format === 'none') + out.format = 'table'; + // A coverage row has exactly 3 cells. Extra cells mean an unescaped pipe in + // a value silently corrupted the row — surface it rather than parse garbage. + if (cleaned.length > 3) { + out.errors.push(`row: ${cleaned.length} columns (expected 3 — unescaped pipe in a cell?)`); + } + out.rows.push({ + capability: cleaned[0] || '', + decision: decisionCell, + reason: (cleaned[2] ?? '').trim(), + }); + } + return out; +} +function rowFromJson(v) { + if (!v || typeof v !== 'object' || Array.isArray(v)) + return { error: 'not an object' }; + const o = v; + const capability = typeof o['capability'] === 'string' ? o['capability'].trim() : ''; + if (!capability) + return { error: 'missing/empty "capability"' }; + const dRaw = typeof o['decision'] === 'string' ? o['decision'].trim().toUpperCase() : ''; + if (!VALID_DECISIONS.has(dRaw)) { + return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` }; + } + const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : ''; + return { capability, decision: dRaw, reason }; +} +/** + * Validate a parsed matrix. A matrix is valid when: + * - it is non-empty (acceptance #1: "enumerating the API surface"), + * - every capability name is non-empty, + * - every decision is INTEGRATE or OPT-OUT (enforced by parser, re-checked + * here for defense-in-depth), + * - every OPT-OUT row carries a non-empty reason (acceptance #2). + * + * Un-enumerated remainder is not representable in the format — the gate blocks + * when an integration is detected and NO matrix exists. This validator catches + * a malformed/partial matrix that does exist. + */ +function validateCoverageMatrix(text) { + const parsed = parseCoverageMatrix(text); + const errors = [...parsed.errors]; + const rows = parsed.rows; + if (rows.length === 0) { + if (errors.length === 0) + errors.push('matrix is empty — no capabilities enumerated'); + return { valid: false, errors, counts: { surface: 0, integrate: 0, optout: 0 } }; + } + const seen = new Set(); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row.capability) { + errors.push(`row[${i}]: empty capability name`); + } + else { + // Format contract + prompt-injection bound: cell values must be short, + // single-line, pipe-free prose (the matrix is a markdown table whose + // content flows into the gate message). Pipes/newlines would corrupt the + // table and let a COVERAGE.md inject unbounded text into the seal message. + if (/[|\n\r]/.test(row.capability)) { + errors.push(`row[${i}]: capability contains a pipe or newline (unsupported in a table cell)`); + } + if (row.capability.length > CAPABILITY_MAX_LEN) { + errors.push(`row[${i}]: capability exceeds ${CAPABILITY_MAX_LEN} chars`); + } + } + if (row.reason && /[|\n\r]/.test(row.reason)) { + errors.push(`row[${i}]: reason contains a pipe or newline (unsupported in a table cell)`); + } + if (row.reason.length > REASON_MAX_LEN) { + errors.push(`row[${i}]: reason exceeds ${REASON_MAX_LEN} chars`); + } + const key = row.capability.toLowerCase(); + if (key && seen.has(key)) + errors.push(`row[${i}]: duplicate capability`); + if (key) + seen.add(key); + if (!VALID_DECISIONS.has(row.decision)) { + errors.push(`row[${i}]: decision not in {INTEGRATE, OPT-OUT}`); + } + if (row.decision === 'OPT-OUT' && !row.reason) { + errors.push(`row[${i}]: OPT-OUT missing reason`); + } + } + const counts = { + surface: rows.length, + integrate: rows.filter((r) => r.decision === 'INTEGRATE').length, + optout: rows.filter((r) => r.decision === 'OPT-OUT').length, + }; + return { valid: errors.length === 0, errors, counts }; +} +/** Render rows back to the canonical markdown-table format (bijective with parse). */ +function renderCoverageMatrix(rows) { + const body = rows + .map((r) => `| ${r.capability} | ${r.decision} | ${r.reason} |`) + .join('\n'); + return `| capability | decision | reason |\n|---|---|---|\n${body}`; +} +// ── CLI entry point ────────────────────────────────────────────────────────── +// Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits. +// Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json] +// Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors +// assumption-delta.cjs / ui-safety-gate.cjs. +if (require.main === module) { + const argv = process.argv.slice(2); + const wantJson = argv.includes('--json'); + let termsOverride; + const verbsIdx = argv.indexOf('--verbs'); + const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined; + const nounsIdx = argv.indexOf('--nouns'); + const nounsVal = nounsIdx !== -1 ? argv[nounsIdx + 1] : undefined; + // A non-empty, non-flag value is an override. An EMPTY value ("") restores + // the curated defaults (does NOT silently zero the vocabulary). + const verbsOverride = typeof verbsVal === 'string' && verbsVal.length > 0 && !verbsVal.startsWith('-'); + const nounsOverride = typeof nounsVal === 'string' && nounsVal.length > 0 && !nounsVal.startsWith('-'); + if (verbsOverride || nounsOverride) { + termsOverride = {}; + if (verbsOverride) { + termsOverride.verbs = verbsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + } + if (nounsOverride) { + termsOverride.nouns = nounsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + } + } + const chunks = []; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => { + const input = chunks.join(''); + const result = detectApiIntegration(input, termsOverride); + if (wantJson) { + process.stdout.write(JSON.stringify(result) + '\n'); + } + process.exit(result.detected ? 0 : 1); + }); + process.stdin.on('error', (err) => { + process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`); + process.exit(2); + }); +} diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 23acba5ba..106f56433 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -39,6 +39,11 @@ const capabilities = { "type": "boolean", "default": true, "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems." + }, + "workflow.api_coverage_gate": { + "type": "boolean", + "default": true, + "description": "Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre the planner is prompted to enumerate the API surface into COVERAGE.md; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out. Independent of ai_integration_phase (applies to any external-API integration, not only AI)." } }, "steps": [ @@ -57,8 +62,35 @@ const capabilities = { "onError": "skip" } ], - "contributions": [], - "gates": [] + "contributions": [ + { + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/api-coverage-plan-pre.md", + "inline": "# API Coverage Decision Checkpoint\n\n> Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase\n> integrates an external API / SDK / service. Most non-API phases will not fire\n> it — that is the point.\n\n## Why this exists\n\n\"We integrated the API\" too often silently means \"we integrated whatever the\nfirst use case exercised.\" Every un-built capability is then an invisible hole,\ndiscovered later by a user who reasonably expected it to work. The phase sealed\ngreen because its tasks completed; nobody decided the gaps were acceptable,\nbecause nobody enumerated them. This checkpoint makes the surface **visible and\ndecided** before the phase can seal.\n\n## Detect whether this phase integrates an external API\n\nThe detector is a deterministic scan over the phase scope. It strips fenced\ncode blocks first, so a trigger term inside a code snippet does not fire. It\nreturns a typed result: `{ detected, signals[], terms }`. Run it on the phase\nscope (the concatenation of this phase's ROADMAP section + the PLAN body):\n\n```bash\nSCOPE=\"$(cat \"${PHASE_DIR}\"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase \"${PHASE}\" 2>/dev/null || true)\"\nAPI_COVERAGE_JSON=$(printf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[]}')\n```\n\nRead `API_COVERAGE_JSON.detected`. Act on it only — do **not** pattern-match the\nprose yourself.\n\n**If `detected` is `false`:** this phase does not integrate an external API. Skip\nthe checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** an external-API integration is in scope. You MUST\nproduce a **coverage matrix** before the plan is finalized.\n\n## Produce the coverage matrix\n\nEnumerate the external API's full **capability surface** — the verb/endpoint/method\nlist (e.g. for a music service: `search`, `play`, `pause`, `skip`, `set_volume`,\n`get_playlist`, `create_playlist`, `add_to_playlist`, …). For each capability\nrecord a decision, starting from **full coverage** as the default:\n\n| capability | decision | reason |\n|---|---|---|\n| `` | `INTEGRATE` \\| `OPT-OUT` | `` |\n\nRules:\n\n- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the\n matrix is the *subtraction record*.\n- **Every `OPT-OUT` MUST carry a one-line reason** (`not needed`, `not needed\n yet`, `explicitly out of scope`, …). An opt-out without a reason is an\n un-decided hole — the exact failure mode this gate exists to close.\n- **A second integration against the same need** (e.g. a second platform for the\n same capability) starts from the **same full-coverage baseline** as the first.\n Do not carry over the first integration's opt-outs silently — re-decide each\n capability for the new surface, so a first-class/fallback asymmetry cannot\n accumulate.\n\nWrite the matrix to `${PHASE_DIR}/COVERAGE.md` (canonical markdown-table form):\n\n```markdown\n# API Coverage — \n\n> Full coverage by default. Opt-outs are explicit, reasoned decisions.\n\n| capability | decision | reason |\n|---|---|---|\n| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | OPT-OUT | not needed yet — tracked for follow-up phase |\n```\n\nA fenced ` ```coverage ` JSON block is also accepted for machine-generated\nmatrices; the markdown table is preferred (human-editable, diff-friendly).\n\n## The seal-time gate\n\nThis checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate\nruns `check api-coverage.verify-pre `:\n\n- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and\n every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**.\n- If `COVERAGE.md` is absent, the detector runs again over the phase scope. If a\n strong external-API-integration signal is found, the seal is **blocked** until a\n matrix is produced. If no signal is found, the phase is treated as a non-API\n phase and the seal proceeds.\n\nSo: an API-integrating phase cannot seal without a decided matrix. Produce it at\nplan time; do not leave it for seal time.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in\n`gsd-core/bin/lib/api-coverage.cjs` (`DEFAULT_API_COVERAGE_TERMS`). To widen it\nfor a project, override at the call site:\n\n```bash\nprintf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json \\\n --verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin\n```\n\nThe whole checkpoint is toggleable via `workflow.api_coverage_gate` in\n`.planning/config.json`.\n" + }, + "produces": [ + "COVERAGE.md" + ], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.api_coverage_gate", + "onError": "skip" + } + ], + "gates": [ + { + "point": "verify:pre", + "check": { + "query": "api-coverage.verify-pre" + }, + "when": "workflow.api_coverage_gate", + "blocking": true, + "onError": "halt" + } + ] }, "antigravity": { "id": "antigravity", @@ -2832,6 +2864,23 @@ const byLoopPoint = { } ], "contributions": [ + { + "capId": "ai-integration", + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/api-coverage-plan-pre.md", + "inline": "# API Coverage Decision Checkpoint\n\n> Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase\n> integrates an external API / SDK / service. Most non-API phases will not fire\n> it — that is the point.\n\n## Why this exists\n\n\"We integrated the API\" too often silently means \"we integrated whatever the\nfirst use case exercised.\" Every un-built capability is then an invisible hole,\ndiscovered later by a user who reasonably expected it to work. The phase sealed\ngreen because its tasks completed; nobody decided the gaps were acceptable,\nbecause nobody enumerated them. This checkpoint makes the surface **visible and\ndecided** before the phase can seal.\n\n## Detect whether this phase integrates an external API\n\nThe detector is a deterministic scan over the phase scope. It strips fenced\ncode blocks first, so a trigger term inside a code snippet does not fire. It\nreturns a typed result: `{ detected, signals[], terms }`. Run it on the phase\nscope (the concatenation of this phase's ROADMAP section + the PLAN body):\n\n```bash\nSCOPE=\"$(cat \"${PHASE_DIR}\"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase \"${PHASE}\" 2>/dev/null || true)\"\nAPI_COVERAGE_JSON=$(printf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[]}')\n```\n\nRead `API_COVERAGE_JSON.detected`. Act on it only — do **not** pattern-match the\nprose yourself.\n\n**If `detected` is `false`:** this phase does not integrate an external API. Skip\nthe checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** an external-API integration is in scope. You MUST\nproduce a **coverage matrix** before the plan is finalized.\n\n## Produce the coverage matrix\n\nEnumerate the external API's full **capability surface** — the verb/endpoint/method\nlist (e.g. for a music service: `search`, `play`, `pause`, `skip`, `set_volume`,\n`get_playlist`, `create_playlist`, `add_to_playlist`, …). For each capability\nrecord a decision, starting from **full coverage** as the default:\n\n| capability | decision | reason |\n|---|---|---|\n| `` | `INTEGRATE` \\| `OPT-OUT` | `` |\n\nRules:\n\n- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the\n matrix is the *subtraction record*.\n- **Every `OPT-OUT` MUST carry a one-line reason** (`not needed`, `not needed\n yet`, `explicitly out of scope`, …). An opt-out without a reason is an\n un-decided hole — the exact failure mode this gate exists to close.\n- **A second integration against the same need** (e.g. a second platform for the\n same capability) starts from the **same full-coverage baseline** as the first.\n Do not carry over the first integration's opt-outs silently — re-decide each\n capability for the new surface, so a first-class/fallback asymmetry cannot\n accumulate.\n\nWrite the matrix to `${PHASE_DIR}/COVERAGE.md` (canonical markdown-table form):\n\n```markdown\n# API Coverage — \n\n> Full coverage by default. Opt-outs are explicit, reasoned decisions.\n\n| capability | decision | reason |\n|---|---|---|\n| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | OPT-OUT | not needed yet — tracked for follow-up phase |\n```\n\nA fenced ` ```coverage ` JSON block is also accepted for machine-generated\nmatrices; the markdown table is preferred (human-editable, diff-friendly).\n\n## The seal-time gate\n\nThis checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate\nruns `check api-coverage.verify-pre `:\n\n- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and\n every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**.\n- If `COVERAGE.md` is absent, the detector runs again over the phase scope. If a\n strong external-API-integration signal is found, the seal is **blocked** until a\n matrix is produced. If no signal is found, the phase is treated as a non-API\n phase and the seal proceeds.\n\nSo: an API-integrating phase cannot seal without a decided matrix. Produce it at\nplan time; do not leave it for seal time.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in\n`gsd-core/bin/lib/api-coverage.cjs` (`DEFAULT_API_COVERAGE_TERMS`). To widen it\nfor a project, override at the call site:\n\n```bash\nprintf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json \\\n --verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin\n```\n\nThe whole checkpoint is toggleable via `workflow.api_coverage_gate` in\n`.planning/config.json`.\n" + }, + "produces": [ + "COVERAGE.md" + ], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.api_coverage_gate", + "onError": "skip" + }, { "capId": "assumption-delta", "point": "plan:pre", @@ -3101,7 +3150,18 @@ const byLoopPoint = { "verify:pre": { "steps": [], "contributions": [], - "gates": [] + "gates": [ + { + "capId": "ai-integration", + "point": "verify:pre", + "check": { + "query": "api-coverage.verify-pre" + }, + "when": "workflow.api_coverage_gate", + "blocking": true, + "onError": "halt" + } + ] }, "verify:post": { "steps": [ @@ -3211,6 +3271,7 @@ const byLoopPoint = { const configKeys = { "workflow.ai_integration_phase": "ai-integration", + "workflow.api_coverage_gate": "ai-integration", "workflow.assumption_delta": "assumption-delta", "claude_orchestration.enabled": "claude-orchestration", "claude_orchestration.execution_backend": "claude-orchestration", @@ -3260,6 +3321,12 @@ const configSchema = { "default": true, "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems." }, + "workflow.api_coverage_gate": { + "owner": "ai-integration", + "type": "boolean", + "default": true, + "description": "Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre the planner is prompted to enumerate the API surface into COVERAGE.md; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out. Independent of ai_integration_phase (applies to any external-API integration, not only AI)." + }, "workflow.assumption_delta": { "owner": "assumption-delta", "type": "boolean", diff --git a/gsd-core/references/api-coverage.md b/gsd-core/references/api-coverage.md new file mode 100644 index 000000000..f5d238f9f --- /dev/null +++ b/gsd-core/references/api-coverage.md @@ -0,0 +1,104 @@ +# API Coverage Gate (Full Coverage by Default — Opt Out, Never Opt In) + +> Reference for the `api-coverage` gate on the `ai-integration` capability (#1562). +> Config key: `workflow.api_coverage_gate` (default `true`). Gate point: `verify:pre`. + +## The problem this closes + +"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 gate makes the API surface **visible and decided** before the phase can +seal. Full coverage is the default starting position; the coverage matrix is the +*subtraction record*. Every gap is an explicit, reasoned opt-out rather than a +surprise. + +## When it fires + +The gate runs at `verify:pre` (before `/gsd:verify-work` begins UAT). A phase is +treated as an external-API integration when **either**: + +1. a `COVERAGE.md` matrix is present in the phase directory (the planner produced + one at `plan:pre`), **or** +2. the phase scope shows a strong external-API-integration signal (an integration + verb co-occurring with an external-API noun, or an explicit ` + API|SDK|REST|GraphQL` surface) and no matrix yet exists. + +Non-API phases (refactors, bug fixes, internal-only work, features that merely +*mention* an existing internal API) do **not** fire the gate — the trigger +requires a compound signal, so a bare word like "api" in "the public API of +UserController" is intentionally ignored. + +## The two touch points + +1. **Plan time (`plan:pre`).** A contribution to the planner prompts it to run + the deterministic detector over the phase scope and, when an integration is + detected, produce `COVERAGE.md`. See + `capabilities/ai-integration/fragments/api-coverage-plan-pre.md`. +2. **Seal time (`verify:pre`).** The blocking `api-coverage.verify-pre` gate + runs `check api-coverage.verify-pre ` and blocks unless a valid + matrix exists (or no integration is detected). + +## The coverage matrix format + +Canonical form — a markdown table (human-editable, diff-friendly): + +```markdown +# API Coverage — + +> 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 | +``` + +- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE. +- **Every `OPT-OUT` MUST 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 fenced ` ```coverage ` JSON block (`[{"capability":…,"decision":…,"reason":…}]`) + is also accepted for machine-generated matrices. + +Rules enforced at seal time: the matrix must be non-empty; every capability name +must be non-empty and unique; every decision must be `INTEGRATE` or `OPT-OUT`; +every `OPT-OUT` must have a reason. Violations block the seal with a precise +error. + +## A second integration against the same need + +A second platform for an existing capability (e.g. adding YouTube alongside +Spotify for media playback) 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 into a later user-facing bug. + +## The matrix persists + +`COVERAGE.md` is a phase artifact. A future phase that extends the same +integration starts from the recorded surface and decisions rather than from +zero — the matrix is the durable subtraction record. + +## Tuning + +- **Disable entirely:** set `workflow.api_coverage_gate: false` in + `.planning/config.json` (the gate unregisters from `verify:pre`). +- **Widen the trigger vocabulary:** the detector accepts `--verbs` / `--nouns` + overrides (see `capabilities/ai-integration/fragments/api-coverage-plan-pre.md`). + The default vocabulary is additive-only. + +## Detector CLI + +```bash +echo "$PHASE_SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json +# exit 0 = integration detected, 1 = none, 2 = startup error +``` + +The detector is a pure function (`detectApiIntegration` → `{ detected, signals, +terms }`) shared by the plan-time prompt and the seal-time gate, so the +low-false-positive guarantee is testable rather than a judgment call. diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index fc24fed3e..23de7a258 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -256,6 +256,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research": | `workflow.node_repair` | boolean | `true` | `true`, `false` | Attempt automatic repair of failed plan nodes | | `workflow.node_repair_budget` | number | `2` | Any positive integer | Max repair retries per failed node | | `workflow.ai_integration_phase` | boolean | `true` | `true`, `false` | Run /gsd:ai-integration-phase before planning AI system phases | +| `workflow.api_coverage_gate` | boolean | `true` | `true`, `false` | Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre prompts a COVERAGE.md matrix; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out (#1562) | | `workflow.ui_phase` | boolean | `true` | `true`, `false` | Generate UI-SPEC.md for frontend phases | | `workflow.ui_safety_gate` | boolean | `true` | `true`, `false` | Require safety gate approval for UI changes | | `workflow.text_mode` | boolean | `false` | `true`, `false` | Use plain-text numbered lists instead of AskUserQuestion menus | diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index 7bacf8577..c3ac9528a 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -58,6 +58,44 @@ MVP_MODE=$(gsd_run query phase.mvp-mode "${phase_number}" ${GSD_WS} --pick activ ``` + +**Verify:pre gate dispatch.** Before verification begins, dispatch every active +gate hook registered at the `verify:pre` loop extension point. Each gate is +data-driven — resolved from the capability registry, not hardcoded here. + +```bash +VERIFY_PRE_HOOKS_JSON=$(gsd_run loop render-hooks verify:pre --raw) +PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty') +``` + +Resolve active gate hooks from `VERIFY_PRE_HOOKS_JSON` where `kind == "gate"`. +For each active gate hook, run its declared check (a `check.query` gate runs +`gsd_run check ${hook.check.query} "${PHASE_DIR}" --raw`; a `predicate` gate +runs `gsd_run check predicate --predicate '' --phase-dir "${PHASE_DIR}" --raw`): + +```bash +GATE_RESULT=$(gsd_run check "${hook_check_query}" "${PHASE_DIR}" --raw) +GATE_BLOCK=$(printf '%s' "$GATE_RESULT" | jq -r '.block // false' 2>/dev/null || echo "false") +``` + +**Two-step gate contract (same as execute:wave:post / execute:post):** + +- **Step 1 — command failure:** if the `gsd_run check ...` invocation itself + fails (non-zero exit, no JSON), route by the gate's `onError`. An `onError: + halt` gate HALTs; an `onError: skip` gate logs a warning and continues. +- **Step 2 — block evaluation:** parse `GATE_RESULT.block`. For a **blocking + gate** (`hook.blocking == true`) with `block == true`: HALT — do not begin UAT, + present the gate's `message`, and tell the user what artifact resolves it. For + a **non-blocking gate** with a non-empty `message`: print + `⚠ {hook.capId} advisory: {GATE_RESULT.message}` and continue. For any gate + with `block == false`: continue silently. + +Example — the `ai-integration` capability's `api-coverage.verify-pre` gate +(when `workflow.api_coverage_gate` is on) blocks here if the phase integrates an +external API without a decided COVERAGE.md matrix. Present its `message` and +point the user at producing COVERAGE.md before re-running verification. + + **First: Check for active UAT sessions** diff --git a/src/api-coverage.cts b/src/api-coverage.cts new file mode 100644 index 000000000..ab0b43d91 --- /dev/null +++ b/src/api-coverage.cts @@ -0,0 +1,514 @@ +/** + * API-Coverage detector + matrix validator (#1562). + * + * The enforcement half of "Full API Coverage by Default — Opt Out, Never Opt In." + * When a phase integrates an external API/service/SDK, the planner must produce a + * coverage matrix (COVERAGE.md) enumerating the API's capability surface; every + * non-integrated capability is an explicit, reasoned opt-out. The seal-time gate + * (capabilities/ai-integration, verify:pre) consumes this module to (a) detect + * whether a phase integrates an external API and (b) validate the produced matrix. + * + * Design notes (rubber-duck'd): + * - DETERMINISTIC + TYPED IR. Both the "does this phase integrate an external + * API?" decision and the "is this matrix complete?" decision are pure + * functions returning typed IR, not LLM judgments — so the low-false-positive + * guarantee (acceptance criterion #4) and the completeness guarantee + * (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561). + * - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in + * countless non-integration phases ("the public API of UserController"). The + * detector requires an INTEGRATION VERB co-occurring with an EXTERNAL-API + * NOUN (or an explicit " API/SDK" phrase). Single weak tokens do not + * fire. This is the issue's "low false-positive trigger" made mechanical. + * - FENCED CODE BLOCKS ARE STRIPPED first (markdown-sectionizer seam) so a + * trigger term inside a code snippet does not fire. + * - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution + * prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is + * ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision + * therefore matters but is not the only line of defense. + * - MATRIX FORMAT. The matrix is a markdown table (human-editable, diff-friendly) + * with a header row `| capability | decision | reason |` and one row per + * capability. decision ∈ {INTEGRATE, OPT-OUT}. An OPT-OUT row MUST carry a + * non-empty reason. A fenced ```coverage JSON block is also accepted for + * machine-generated matrices. This dual shape is bijective (parse/render + * round-trip) and covered by a fast-check property test. + * - ADDITIVE-ONLY VOCABULARY (Hyrum's Law). Once shipped, the verb/noun sets + * are depended-upon interfaces; they only grow. Tunable via the `terms` + * parameter so teams can widen them without forking. + * + * Public API: + * detectApiIntegration(text, terms?) -> { detected, signals, terms } + * parseCoverageMatrix(text) -> { rows, errors, format } + * validateCoverageMatrix(text) -> { valid, errors, counts } + * renderCoverageMatrix(rows) -> string + * DEFAULT_API_COVERAGE_TERMS + * + * CLI: + * echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json] + * exit 0 = integration detected, 1 = none, 2 = startup error + */ + +import { stripFencedCode } from './markdown-sectionizer.cjs'; + +// ─── Integration-signal vocabulary ──────────────────────────────────────────── + +export interface ApiCoverageTermSet { + verbs: string[]; + nouns: string[]; +} + +export interface ApiCoverageSignal { + verb: string; + noun: string; + snippet: string; +} + +export interface ApiCoverageDetectionResult { + detected: boolean; + signals: ApiCoverageSignal[]; + terms: ApiCoverageTermSet; +} + +/** + * Curated default trigger vocabulary. ADDITIVE-ONLY (Hyrum's Law). Tunable via + * the `terms` parameter. + * + * VERBS are deliberately conservative: common verbs like "add", "use", "call", + * "implement" are EXCLUDED because they appear in nearly every phase and would + * make the gate fire on prose that has nothing to do with an external API. The + * verbs kept all connote BRINGING IN an external surface. + * + * NOUNS name an external-API surface. Bare "client" is excluded — too ambiguous + * (client-side UI vs API client). "service" alone is excluded (internal + * services); a phase integrating an external service virtually always pairs it + * with "API"/"SDK"/"REST"/etc., which the compound verb+noun rule captures. + */ +export const DEFAULT_API_COVERAGE_TERMS: Readonly = { + verbs: [ + 'integrate', + 'integrates', + 'integrating', + 'integration', + 'wrap', + 'wraps', + 'wrapping', + 'connect', + 'connects', + 'connecting', + 'consume', + 'consumes', + 'consuming', + 'wire', + 'wires', + 'wiring', + 'onboard', + 'onboarding', + 'adopt', + 'adopts', + 'adopting', + ], + nouns: [ + 'api', + 'apis', + 'sdk', + 'sdks', + 'rest', + 'graphql', + 'grpc', + 'endpoint', + 'endpoints', + 'oauth', + 'oauth2', + 'webhook', + 'webhooks', + 'mcp', + ], +}; + +/** Hardening caps for the tunable vocabulary (hostile `--terms` defense). */ +const MAX_TERMS_PER_KIND = 200; +const MAX_TERM_LEN = 32; + +/** + * Field-length caps for matrix cell values. Cell content flows from a + * semi-trusted COVERAGE.md into the gate `message` that the orchestrator LLM + * reads, so it is bounded to keep the prompt-injection surface small and to + * document the format contract (short, single-line prose — not paragraphs). + */ +const CAPABILITY_MAX_LEN = 80; +const REASON_MAX_LEN = 200; + +function normalizeTerms(list: unknown): string[] { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out: string[] = []; + for (const raw of list) { + if (typeof raw !== 'string') continue; + const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); + if (!t || !/[a-z0-9]/.test(t)) continue; + if (seen.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= MAX_TERMS_PER_KIND) break; + } + return out; +} + +function resolveTerms(terms?: Partial): ApiCoverageTermSet { + const merge = (key: 'verbs' | 'nouns'): string[] => { + const t = terms && terms[key]; + return Array.isArray(t) ? normalizeTerms(t) : [...DEFAULT_API_COVERAGE_TERMS[key]]; + }; + return { verbs: merge('verbs'), nouns: merge('nouns') }; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function makeSnippet(line: string, anchor: string): string { + const cleaned = line.replace(/\s+/g, ' ').trim(); + if (cleaned.length <= 120) return cleaned; + const idx = cleaned.toLowerCase().indexOf(anchor); + if (idx < 0) return cleaned.slice(0, 120); + const start = Math.max(0, idx - 50); + const end = Math.min(cleaned.length, idx + anchor.length + 50); + const prefix = start > 0 ? '…' : ''; + const suffix = end < cleaned.length ? '…' : ''; + return `${prefix}${cleaned.slice(start, end)}${suffix}`; +} + +/** ` API` / ` SDK` — a capitalized proper noun immediately + * followed by API/SDK. Strong signal on its own (no verb required). + * + * STOPWORDS guard against the false positive where an ordinary capitalized + * sentence starter ("The API …", "An SDK …", "Our REST …") matches the + * `[A-Z]\w+ API` shape. Those are common English, not a service name, so they + * are rejected before counting as a surface signal (acceptance #4 — low false + * positives). */ +const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,})\s+(API|SDK|REST|GraphQL)\b/; +const SERVICE_STOPWORDS = new Set([ + 'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add', + 'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both', + 'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their', + 'we', 'you', 'they', 'it', +]); + +/** + * Detect whether phase-scope prose describes integrating an external API/SDK. + * + * Fires when EITHER: + * (a) a compound verb+noun signal co-occurs on the same line, OR + * (b) an explicit ` API|SDK|REST|GraphQL` surface appears. + * + * Non-string inputs degrade to `{ detected: false }` without throwing. + */ +export function detectApiIntegration( + text: unknown, + terms?: Partial, +): ApiCoverageDetectionResult { + const effective = resolveTerms(terms); + if (typeof text !== 'string') { + return { detected: false, signals: [], terms: effective }; + } + + const stripped = stripFencedCode(text.replace(/\r\n/g, '\n')).text; + if (stripped.trim().length === 0) { + return { detected: false, signals: [], terms: effective }; + } + + const signals: ApiCoverageSignal[] = []; + const seen = new Set(); + const lines = stripped.split('\n'); + + // (a) compound verb+noun on the same line. + if (effective.verbs.length > 0 && effective.nouns.length > 0) { + const verbRe = new RegExp( + '(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', + 'gi', + ); + const nounRe = new RegExp( + '(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', + 'gi', + ); + for (const line of lines) { + verbRe.lastIndex = 0; + nounRe.lastIndex = 0; + const vMatch = verbRe.exec(line); + if (!vMatch) continue; + const nMatch = nounRe.exec(line); + if (!nMatch) continue; + const verb = (vMatch[2] || '').toLowerCase(); + const noun = (nMatch[2] || '').toLowerCase(); + const key = `${verb}+${noun}`; + if (seen.has(key)) continue; + seen.add(key); + signals.push({ verb, noun, snippet: makeSnippet(line, noun) }); + } + } + + // (b) explicit API|SDK|REST|GraphQL surface. + for (const line of lines) { + SERVICE_SURFACE_API_RE.lastIndex = 0; + const m = SERVICE_SURFACE_API_RE.exec(line); + if (!m) continue; + // Reject ordinary capitalized sentence starters ("The API …", "Our REST …"). + if (SERVICE_STOPWORDS.has((m[1] || '').toLowerCase())) continue; + const noun = (m[2] || '').toLowerCase(); + const key = `surface+${noun}`; + if (seen.has(key)) continue; + seen.add(key); + signals.push({ verb: '(surface)', noun, snippet: makeSnippet(line, m[1]) }); + } + + return { detected: signals.length > 0, signals, terms: effective }; +} + +// ─── Coverage matrix parse / validate / render ──────────────────────────────── + +export type CoverageDecision = 'INTEGRATE' | 'OPT-OUT'; + +export interface CoverageRow { + capability: string; + decision: CoverageDecision; + reason: string; +} + +export interface CoverageParseResult { + rows: CoverageRow[]; + errors: string[]; + format: 'table' | 'json' | 'none'; +} + +export interface CoverageValidationResult { + valid: boolean; + errors: string[]; + counts: { surface: number; integrate: number; optout: number }; +} + +const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']); + +/** + * Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats: + * + * 1. Markdown table (canonical, human-editable): + * | capability | decision | reason | + * |---|---|---| + * | search | INTEGRATE | | + * | playlists | OPT-OUT | not needed yet | + * + * 2. Fenced ```coverage JSON block (machine-generated): + * ```coverage + * [ {"capability":"search","decision":"INTEGRATE","reason":""}, ... ] + * ``` + * + * Rows are trimmed; decisions upper-cased; missing reason → "". Returns + * `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input. + */ +export function parseCoverageMatrix(text: unknown): CoverageParseResult { + const out: CoverageParseResult = { rows: [], errors: [], format: 'none' }; + if (typeof text !== 'string') return out; + const src = text.replace(/\r\n/g, '\n'); + + // (1) fenced ```coverage JSON block takes precedence if present. + // Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark). + // allow-adhoc-markdown: extracting a NAMED ```coverage fence (extraction of one tagged block), not stripping all fences — stripFencedCode/extractTaggedBlocks do not cover named-fence extraction. + const fenceMatch = src.match(/```coverage\s*\n([\s\S]*?)\n```/i); + if (fenceMatch && fenceMatch[1]) { + out.format = 'json'; + let parsed: unknown; + try { + parsed = JSON.parse(fenceMatch[1]); + } catch { + out.errors.push('fenced ```coverage block is not valid JSON'); + return out; + } + if (!Array.isArray(parsed)) { + out.errors.push('fenced ```coverage block must be a JSON array'); + return out; + } + for (let i = 0; i < parsed.length; i++) { + const row = rowFromJson(parsed[i]); + if ('error' in row) { + out.errors.push(`row[${i}]: ${row.error}`); + continue; + } + out.rows.push(row); + } + return out; + } + + // (2) markdown table — collect table rows whose decision column parses. + const lines = src.split('\n'); + let sawHeader = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|'); + if (cells.length < 2) continue; + const cleaned = cells.map((c) => c.trim()); + // skip separator rows (|---|---|); require ≥3 dashes so a literal "-" cell + // is not mistaken for a separator. + if (cleaned.every((c) => /^:?-{3,}:?$/.test(c))) continue; + const decisionCell = (cleaned[1] || '').toUpperCase(); + // header detection + if (!sawHeader && cleaned[0].toLowerCase() === 'capability') { + sawHeader = true; + out.format = 'table'; + continue; + } + if (!VALID_DECISIONS.has(decisionCell as CoverageDecision)) { + // A row that otherwise looks like data (≥3 cells, non-empty capability) + // but carries a malformed decision is a real error, not a row to skip + // silently — otherwise a single typo'd row collapses the matrix to + // "empty" and the user sees a confusing message. + if (cleaned.length >= 3 && cleaned[0]) { + out.errors.push(`row: decision "${decisionCell}" not in {INTEGRATE, OPT-OUT}`); + } + continue; + } + if (out.format === 'none') out.format = 'table'; + // A coverage row has exactly 3 cells. Extra cells mean an unescaped pipe in + // a value silently corrupted the row — surface it rather than parse garbage. + if (cleaned.length > 3) { + out.errors.push(`row: ${cleaned.length} columns (expected 3 — unescaped pipe in a cell?)`); + } + out.rows.push({ + capability: cleaned[0] || '', + decision: decisionCell as CoverageDecision, + reason: (cleaned[2] ?? '').trim(), + }); + } + return out; +} + +function rowFromJson(v: unknown): CoverageRow | { error: string } { + if (!v || typeof v !== 'object' || Array.isArray(v)) return { error: 'not an object' }; + const o = v as Record; + const capability = typeof o['capability'] === 'string' ? o['capability'].trim() : ''; + if (!capability) return { error: 'missing/empty "capability"' }; + const dRaw = typeof o['decision'] === 'string' ? o['decision'].trim().toUpperCase() : ''; + if (!VALID_DECISIONS.has(dRaw as CoverageDecision)) { + return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` }; + } + const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : ''; + return { capability, decision: dRaw as CoverageDecision, reason }; +} + +/** + * Validate a parsed matrix. A matrix is valid when: + * - it is non-empty (acceptance #1: "enumerating the API surface"), + * - every capability name is non-empty, + * - every decision is INTEGRATE or OPT-OUT (enforced by parser, re-checked + * here for defense-in-depth), + * - every OPT-OUT row carries a non-empty reason (acceptance #2). + * + * Un-enumerated remainder is not representable in the format — the gate blocks + * when an integration is detected and NO matrix exists. This validator catches + * a malformed/partial matrix that does exist. + */ +export function validateCoverageMatrix(text: unknown): CoverageValidationResult { + const parsed = parseCoverageMatrix(text); + const errors = [...parsed.errors]; + const rows = parsed.rows; + + if (rows.length === 0) { + if (errors.length === 0) errors.push('matrix is empty — no capabilities enumerated'); + return { valid: false, errors, counts: { surface: 0, integrate: 0, optout: 0 } }; + } + + const seen = new Set(); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row.capability) { + errors.push(`row[${i}]: empty capability name`); + } else { + // Format contract + prompt-injection bound: cell values must be short, + // single-line, pipe-free prose (the matrix is a markdown table whose + // content flows into the gate message). Pipes/newlines would corrupt the + // table and let a COVERAGE.md inject unbounded text into the seal message. + if (/[|\n\r]/.test(row.capability)) { + errors.push(`row[${i}]: capability contains a pipe or newline (unsupported in a table cell)`); + } + if (row.capability.length > CAPABILITY_MAX_LEN) { + errors.push(`row[${i}]: capability exceeds ${CAPABILITY_MAX_LEN} chars`); + } + } + if (row.reason && /[|\n\r]/.test(row.reason)) { + errors.push(`row[${i}]: reason contains a pipe or newline (unsupported in a table cell)`); + } + if (row.reason.length > REASON_MAX_LEN) { + errors.push(`row[${i}]: reason exceeds ${REASON_MAX_LEN} chars`); + } + const key = row.capability.toLowerCase(); + if (key && seen.has(key)) errors.push(`row[${i}]: duplicate capability`); + if (key) seen.add(key); + if (!VALID_DECISIONS.has(row.decision)) { + errors.push(`row[${i}]: decision not in {INTEGRATE, OPT-OUT}`); + } + if (row.decision === 'OPT-OUT' && !row.reason) { + errors.push(`row[${i}]: OPT-OUT missing reason`); + } + } + + const counts = { + surface: rows.length, + integrate: rows.filter((r) => r.decision === 'INTEGRATE').length, + optout: rows.filter((r) => r.decision === 'OPT-OUT').length, + }; + + return { valid: errors.length === 0, errors, counts }; +} + +/** Render rows back to the canonical markdown-table format (bijective with parse). */ +export function renderCoverageMatrix(rows: readonly CoverageRow[]): string { + const body = rows + .map((r) => `| ${r.capability} | ${r.decision} | ${r.reason} |`) + .join('\n'); + return `| capability | decision | reason |\n|---|---|---|\n${body}`; +} + +// ── CLI entry point ────────────────────────────────────────────────────────── +// Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits. +// Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json] +// Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors +// assumption-delta.cjs / ui-safety-gate.cjs. + +if (require.main === module) { + const argv = process.argv.slice(2); + const wantJson = argv.includes('--json'); + + let termsOverride: Partial | undefined; + const verbsIdx = argv.indexOf('--verbs'); + const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined; + const nounsIdx = argv.indexOf('--nouns'); + const nounsVal = nounsIdx !== -1 ? argv[nounsIdx + 1] : undefined; + // A non-empty, non-flag value is an override. An EMPTY value ("") restores + // the curated defaults (does NOT silently zero the vocabulary). + const verbsOverride = typeof verbsVal === 'string' && verbsVal.length > 0 && !verbsVal.startsWith('-'); + const nounsOverride = typeof nounsVal === 'string' && nounsVal.length > 0 && !nounsVal.startsWith('-'); + if (verbsOverride || nounsOverride) { + termsOverride = {}; + if (verbsOverride) { + termsOverride.verbs = verbsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + } + if (nounsOverride) { + termsOverride.nouns = nounsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + } + } + + const chunks: string[] = []; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk: string) => chunks.push(chunk)); + process.stdin.on('end', () => { + const input = chunks.join(''); + const result = detectApiIntegration(input, termsOverride); + if (wantJson) { + process.stdout.write(JSON.stringify(result) + '\n'); + } + process.exit(result.detected ? 0 : 1); + }); + process.stdin.on('error', (err: Error) => { + process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`); + process.exit(2); + }); +} diff --git a/src/check-command-router.cts b/src/check-command-router.cts index c3201b502..3a712bb14 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -35,6 +35,9 @@ import { routeProhibitionEnforcement } from './prohibition-enforcement.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import gatePredicateEval = require('./gate-predicate-evaluator.cjs'); const { evaluatePredicate } = gatePredicateEval; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import apiCoverageMod = require('./api-coverage.cjs'); +const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod; import { execTool } from './shell-command-projection.cjs'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -986,6 +989,279 @@ function cmdCheckPredicate(projectDir: string, args: string[], raw: boolean): vo output(result, raw, undefined); } +// ─── api-coverage-verify-pre ────────────────────────────────────────────────── + +/** + * api-coverage.verify-pre: BLOCKING seal-time gate for the ai-integration + * capability (#1562). Enforces "Full API Coverage by Default — Opt Out, Never + * Opt In." A phase that integrates an external API/SDK/service may not seal + * until a COVERAGE.md matrix enumerates the surface and every non-integrated + * capability is an explicit, reasoned opt-out. + * + * Contract (two touch points composed into one check): + * 1. If COVERAGE.md exists in the phase dir → validate it (acceptance #2). + * Block on any validation error (empty matrix, OPT-OUT without reason, + * duplicate/empty capability). + * 2. If COVERAGE.md is absent → run detectApiIntegration over the phase scope + * (PLAN.md body, then ROADMAP phase section as fallback). If a strong + * external-API-integration signal is detected → BLOCK ("integration + * detected without coverage matrix"). If no signal → PASS (treat as a + * non-API phase; acceptance #4 — low false positives). + * + * The detector is the FALLBACK for the "nobody decided / forgot the matrix" + * case; the primary path is the plan:pre contribution prompting COVERAGE.md. + * + * Args: check api-coverage.verify-pre + * Emits the uniform gate contract: { block, passed, message, ...details }. + */ +function cmdApiCoverageVerifyPre(projectDir: string, args: string[], raw: boolean): void { + const phaseArg = typeof args[2] === 'string' ? args[2] : ''; + if (!phaseArg) { + error( + 'api-coverage.verify-pre requires a phase argument: check api-coverage.verify-pre ', + ERROR_REASON.SDK_MISSING_ARG, + ); + return; + } + + const pDir = planningDir(projectDir); + const phasesRoot = path.join(pDir, 'phases'); + + // SECURITY (path traversal): the phase argument is taken ONLY as a phase + // token — its basename — and resolved by findPhaseInternal strictly under + // .planning/phases/ (or a milestone archive). The raw arg is never used as a + // path, so `..`, absolute paths, and arbitrary directories cannot reach a + // file read. Mirrors cmdVerifySchemaDrift's token-match approach. + let token = phaseArg.replace(/\\/g, '/').split('/').filter(Boolean).pop() || ''; + // A token like ".." or "." carries no phase identity → unresolvable. + if (token === '.' || token === '..') token = ''; + + // Not a GSD project (no phases tree at all) → fail-open: nothing to gate. + if (!fs.existsSync(phasesRoot)) { + output( + { + block: false, + passed: true, + coverage_present: false, + detected: false, + message: 'api-coverage: no .planning/phases directory; gate skipped (not a GSD project layout)', + }, + raw, + undefined, + ); + return; + } + + // Resolve the phase dir under the contained phases root. + let resolvedDir: string | null = null; + let phaseNumber = ''; + if (token) { + const found = findPhaseInternal(projectDir, token); + if (found && found.directory) { + resolvedDir = found.directory; + phaseNumber = found.phase_number || ''; + } + } + + if (!resolvedDir) { + // The phases tree EXISTS but THIS phase could not be resolved. For a + // BLOCKING gate, fail-closed: a missing phase dir must not silently bypass + // the coverage requirement. (Distinguished from "no .planning at all" + // above, which is a genuine non-GSD-project → pass.) + output( + { + block: true, + passed: false, + coverage_present: false, + detected: false, + phase_lookup_failed: true, + message: + `api-coverage: could not resolve phase "${phaseArg}" under .planning/phases/. ` + + 'Resolve the phase directory (or produce COVERAGE.md) before sealing.', + }, + raw, + undefined, + ); + return; + } + + // Defense-in-depth: the resolved dir must be inside the phases root (or a + // milestone archive under .planning/milestones). + const milestonesRoot = path.join(pDir, 'milestones'); + if (!isInsideRoot(resolvedDir, phasesRoot) && !isInsideRoot(resolvedDir, milestonesRoot)) { + output( + { + block: true, + passed: false, + coverage_present: false, + detected: false, + message: 'api-coverage: resolved phase dir escapes .planning/ — refusing to evaluate', + }, + raw, + undefined, + ); + return; + } + + // (1) locate COVERAGE.md — prefer the exact name, then a single *-COVERAGE.md. + let coverageFile = ''; + let suffixed: string[] = []; + try { + const entries = fs.readdirSync(resolvedDir, { withFileTypes: true }); + const files = entries.filter((e) => e.isFile()).map((e) => e.name); + const exact = files.find((f) => /^COVERAGE\.md$/i.test(f)); + if (exact) { + coverageFile = exact; + } else { + suffixed = files.filter((f) => /-COVERAGE\.md$/i.test(f)).sort(); + if (suffixed.length === 1) coverageFile = suffixed[0]; + } + } catch { + // readdir failure → treat as no matrix readable; fall through to detection. + } + + if (coverageFile) { + let matrixText: string; + try { + matrixText = fs.readFileSync(path.join(resolvedDir, coverageFile), 'utf8'); + } catch { + // COVERAGE.md exists but is unreadable (EACCES/EIO/encoding). Fail-closed + // with a useful message rather than a raw throw. + output( + { + block: true, + passed: false, + coverage_present: true, + message: `api-coverage: COVERAGE.md exists but is unreadable — fix file permissions/encoding before sealing`, + }, + raw, + undefined, + ); + return; + } + const v = validateCoverageMatrix(matrixText); + if (v.valid) { + output( + { + block: false, + passed: true, + coverage_present: true, + matrix: coverageFile, + counts: v.counts, + message: `api-coverage: matrix present (${v.counts.surface} capabilities, ${v.counts.optout} opt-out)`, + }, + raw, + undefined, + ); + return; + } + // Fixed-template message (no raw cell content echoed into the LLM-facing + // message). The structured `errors` array is safe (row-indexed, no cell + // values) and travels as data for tooling that wants detail. + output( + { + block: true, + passed: false, + coverage_present: true, + matrix: coverageFile, + error_count: v.errors.length, + errors: v.errors, + message: `api-coverage: COVERAGE.md has ${v.errors.length} problem(s) — fix the matrix (every capability INTEGRATE or OPT-OUT with a reason) before sealing`, + }, + raw, + undefined, + ); + return; + } + if (suffixed.length > 1) { + output( + { + block: true, + passed: false, + coverage_present: false, + message: `api-coverage: multiple *-COVERAGE.md files found (${suffixed.length}) — consolidate into one COVERAGE.md before sealing`, + }, + raw, + undefined, + ); + return; + } + + // (2) no matrix — detect whether this phase integrates an external API. + const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber); + const detection = detectApiIntegration(scopeText); + if (detection.detected) { + // Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the + // gate output cannot relay injected PLAN.md instructions to the orchestrator. + const signals = detection.signals.map((s) => ({ verb: s.verb, noun: s.noun })); + output( + { + block: true, + passed: false, + coverage_present: false, + detected: true, + signals, + message: + 'api-coverage: external-API integration detected without a coverage matrix. ' + + 'Produce COVERAGE.md enumerating the API surface (every capability INTEGRATE or ' + + 'OPT-OUT with a reason) before sealing. Full coverage is the default.', + }, + raw, + undefined, + ); + return; + } + + output( + { + block: false, + passed: true, + coverage_present: false, + detected: false, + message: 'api-coverage: no external-API integration detected; coverage matrix not required', + }, + raw, + undefined, + ); +} + +/** + * Read the phase-scope text used for API-integration detection. Uses the + * resolved plan files (PLAN.md bodies — the planner's own words about what the + * phase does) and, as a fallback, ONLY THIS PHASE'S ROADMAP section (not the + * whole roadmap, which would cross-contaminate sibling phases). Strips nothing + * here — detectApiIntegration strips fenced code itself. + */ +function readPhaseScope(projectDir: string, phaseDir: string, phaseNumber: string): string { + const chunks: string[] = []; + try { + const entries = fs.readdirSync(phaseDir, { withFileTypes: true }); + const plans = entries + .filter((e) => e.isFile() && /-PLAN\.md$/i.test(e.name)) + .map((e) => e.name) + .sort(); + for (const p of plans) { + chunks.push(fs.readFileSync(path.join(phaseDir, p), 'utf8')); + } + } catch { + // ignore — fall through to roadmap + } + if (chunks.join('').trim().length > 0) return chunks.join('\n\n'); + + // Fallback: ONLY this phase's ROADMAP section (not the whole file, which + // would pollute detection with sibling-phase prose). Best-effort; absence or + // an unresolvable section is non-fatal (detector returns not-detected). + if (phaseNumber) { + try { + const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber); + if (section) return section; + } catch { + // ignore + } + } + return ''; +} + function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { // Normalize dots to hyphens in the subcommand so both forms are accepted. // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates) @@ -1015,6 +1291,12 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { cmdGapAnalysisPlanPost(cwd, args, raw); return; } + if (subcommand === 'api-coverage-verify-pre') { + // ai-integration capability blocking gate at verify:pre (#1562). Dot-to- + // hyphen normalization means query "api-coverage.verify-pre" routes here. + cmdApiCoverageVerifyPre(cwd, args, raw); + return; + } if (subcommand === 'tdd-review-checkpoint') { cmdTddReviewCheckpoint(cwd, args, raw); return; @@ -1055,7 +1337,7 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { routeProhibitionEnforcement(args, raw); return; } - error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); + error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); } export = { diff --git a/src/config-loader.cts b/src/config-loader.cts index 5f5e79ec1..2384f60a2 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -108,6 +108,7 @@ const CONFIG_DEFAULTS = { verifier: _getNestedConfigDefault('workflow', 'verifier'), nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'), ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'), + api_coverage_gate: _getNestedConfigDefault('workflow', 'api_coverage_gate'), parallelization: _getConfigDefault('parallelization'), brave_search: _getConfigDefault('brave_search'), firecrawl: _getConfigDefault('firecrawl'), diff --git a/src/config.cts b/src/config.cts index 0e54cabaa..a0989f78c 100644 --- a/src/config.cts +++ b/src/config.cts @@ -246,6 +246,7 @@ function buildNewProjectConfig(userChoices: Record): Record t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); + try { + const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...TEST_ENV_BASE }, + timeout: 60000, + }); + return { success: true, output: stdout.trim(), exitCode: 0, error: '' }; + } catch (err) { + return { + success: false, + output: err.stdout?.toString().trim() || '', + error: err.stderr?.toString().trim() || err.message, + exitCode: err.status ?? 1, + }; + } +} + +function makeProject(workflow) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-apicov-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ workflow }), + 'utf8' + ); + return tmpDir; +} + +function makePhaseDir(projectDir, phaseSlug) { + const dir = path.join(projectDir, '.planning', 'phases', phaseSlug); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writePlan(phaseDir, planFile, body) { + fs.writeFileSync(path.join(phaseDir, planFile), body, 'utf8'); +} + +function writeCoverage(phaseDir, body) { + fs.writeFileSync(path.join(phaseDir, 'COVERAGE.md'), body, 'utf8'); +} + +function verifyPreHooks(cwd) { + const result = runTools('loop render-hooks verify:pre --raw', cwd); + assert.ok(result.success, `render-hooks verify:pre should succeed. stderr: ${result.error}`); + const envelope = JSON.parse(result.output); + assert.strictEqual(envelope.point, 'verify:pre', 'point field must be verify:pre'); + assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array'); + return envelope; +} + +function findCap(envelope, capId) { + return envelope.activeHooks.find((h) => h.capId === capId) || null; +} + +function runGate(cwd, phaseDir) { + return runTools(['check', 'api-coverage.verify-pre', phaseDir, '--raw'], cwd); +} + +// ─── Capability wiring: data-driven activation (acceptance #5) ─────────────── + +describe('api-coverage verify:pre gate — capability wiring (#1562 acceptance #5)', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('gate is ACTIVE when workflow.api_coverage_gate is true', () => { + tmpDir = makeProject({ api_coverage_gate: true }); + const env = verifyPreHooks(tmpDir); + const hook = findCap(env, 'ai-integration'); + assert.ok(hook, 'ai-integration gate must register at verify:pre when enabled'); + assert.strictEqual(hook.kind, 'gate'); + assert.strictEqual(hook.blocking, true); + assert.strictEqual(hook.check.query, 'api-coverage.verify-pre'); + }); + + test('gate is ABSENT when workflow.api_coverage_gate is false', () => { + tmpDir = makeProject({ api_coverage_gate: false }); + const env = verifyPreHooks(tmpDir); + assert.strictEqual(findCap(env, 'ai-integration'), null, 'gate must not register when disabled'); + }); + + test('gate is ACTIVE by default when the key is absent (opt-out, not opt-in)', () => { + tmpDir = makeProject({}); + const env = verifyPreHooks(tmpDir); + assert.ok(findCap(env, 'ai-integration'), 'gate must default ON (full-coverage-by-default)'); + }); +}); + +// ─── Seal contract: block / pass (acceptance #1, #2, #4, #6) ────────────────── + +describe('api-coverage.verify-pre — seal contract (#1562 acceptance #1,#2,#4,#6)', () => { + let tmpDir; + let phaseDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + function fresh() { + tmpDir = makeProject({ api_coverage_gate: true }); + phaseDir = makePhaseDir(tmpDir, '01-pay'); + return phaseDir; + } + + test('#1 API phase without a matrix → BLOCKS the seal', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payment processing.'); + const r = runGate(tmpDir, phaseDir); + assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true, 'must block when API integration has no matrix'); + assert.strictEqual(j.detected, true); + assert.strictEqual(j.coverage_present, false); + }); + + test('#4 non-API phase without a matrix → does NOT block', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nRefactor the auth helper to use bcrypt.'); + const r = runGate(tmpDir, phaseDir); + assert.ok(r.success, `gate should succeed. stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false, 'must not block a non-API phase'); + assert.strictEqual(j.detected, false); + }); + + test('#1/#6 API phase WITH a valid matrix → passes (matrix persists on disk)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payment processing.'); + writeCoverage( + phaseDir, + '| capability | decision | reason |\n|---|---|---|\n' + + '| charge | INTEGRATE | |\n| refund | OPT-OUT | not needed yet |\n' + ); + const r = runGate(tmpDir, phaseDir); + assert.ok(r.success, `gate should succeed. stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false); + assert.strictEqual(j.coverage_present, true); + assert.strictEqual(j.counts.surface, 2); + assert.strictEqual(j.counts.optout, 1); + }); + + test('#2 OPT-OUT without a reason → BLOCKS (un-decided hole)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.'); + writeCoverage( + phaseDir, + '| capability | decision | reason |\n|---|---|---|\n| refund | OPT-OUT | |\n' + ); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true, 'opt-out without reason must block'); + assert.ok(j.errors.some((e) => /missing reason/i.test(e))); + }); + + test('#2 empty matrix → BLOCKS (surface must be enumerated)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.'); + writeCoverage(phaseDir, '| capability | decision | reason |\n|---|---|---|\n'); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true); + assert.ok(j.errors.some((e) => /empty/i.test(e))); + }); + + test('#3 a second platform with full-coverage baseline is accepted (no asymmetry)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nAdd a YouTube SDK as a second media platform.'); + // Full-coverage baseline for the second platform: every capability decided. + writeCoverage( + phaseDir, + '| capability | decision | reason |\n|---|---|---|\n' + + '| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | INTEGRATE | |\n' + ); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false, 'a fully-decided second platform seals clean'); + assert.strictEqual(j.counts.surface, 3); + }); + + test('JSON-fenced matrix is accepted (machine-generated form)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.'); + writeCoverage( + phaseDir, + '```coverage\n[{"capability":"charge","decision":"INTEGRATE","reason":""}]\n```\n' + ); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false); + assert.strictEqual(j.counts.surface, 1); + }); + + // ── Security (#1562 security review S1/S2): the phase arg is taken only as a + // token resolved under .planning/phases/. Traversal / unresolvable args must + // NOT read files outside the phase dir, and — since the phases tree exists — + // must fail CLOSED (a blocking gate must not silently bypass on a bad arg). + test('path-traversal arg is contained and fails CLOSED (phases tree exists)', () => { + fresh(); // creates .planning/phases/01-pay + const r = runTools(['check', 'api-coverage.verify-pre', '../../etc', '--raw'], tmpDir); + assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true, 'unresolvable phase under an existing phases tree must block'); + assert.strictEqual(j.phase_lookup_failed, true); + }); + + test('no .planning/phases at all → fail-open (genuine non-GSD project)', () => { + // A project with .planning/config.json but no phases directory. + const noPhases = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-apicov-nophase-')); + try { + fs.mkdirSync(path.join(noPhases, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(noPhases, '.planning', 'config.json'), + JSON.stringify({ workflow: { api_coverage_gate: true } }), + 'utf8' + ); + const r = runTools(['check', 'api-coverage.verify-pre', '01-pay', '--raw'], noPhases); + assert.ok(r.success); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false, 'no phases tree → pass (not a GSD project)'); + } finally { + cleanup(noPhases); + } + }); +}); diff --git a/tests/api-coverage.test.cjs b/tests/api-coverage.test.cjs new file mode 100644 index 000000000..9bff9ab45 --- /dev/null +++ b/tests/api-coverage.test.cjs @@ -0,0 +1,410 @@ +/** + * Tests for the API-coverage detector + matrix validator (#1562). + * + * Two pure functions under test, both returning typed IR (asserted, never + * text-matched on prose): + * - detectApiIntegration(text) -> { detected, signals, terms } + * - validateCoverageMatrix(text) -> { valid, errors, counts } + * + * Acceptance-criterion mapping: + * #2 (opt-out needs reason; un-enumerated blocks) → validateCoverageMatrix suite + * #4 (non-API phases unaffected, low false-positive) → false-positive suite + * Matrix parse/render bijectivity → fast-check property + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const fc = require('fast-check'); + +const MODULE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'api-coverage.cjs'); + +describe('detectApiIntegration — pure detector (#1562)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + throw new Error( + `Could not require ${MODULE_PATH}. Run "npm run build:lib" first. Underlying: ${err.message}` + ); + } + const { detectApiIntegration, DEFAULT_API_COVERAGE_TERMS } = mod; + + test('result shape — always carries detected, signals[], terms', () => { + const r = detectApiIntegration('refactor the login function'); + assert.strictEqual(r.detected, false); + assert(Array.isArray(r.signals)); + assert.strictEqual(r.signals.length, 0); + assert.ok(r.terms && Array.isArray(r.terms.verbs)); + assert.ok(Array.isArray(r.terms.nouns)); + }); + + test('non-string input degrades to {detected:false} without throwing', () => { + assert.strictEqual(detectApiIntegration(undefined).detected, false); + assert.strictEqual(detectApiIntegration(null).detected, false); + assert.strictEqual(detectApiIntegration(42).detected, false); + assert.strictEqual(detectApiIntegration({}).detected, false); + }); + + test('empty / whitespace-only input does not fire', () => { + assert.strictEqual(detectApiIntegration('').detected, false); + assert.strictEqual(detectApiIntegration(' \n\t ').detected, false); + }); + + test('terms echo is the effective set actually used', () => { + const r = detectApiIntegration('nothing relevant here'); + assert.deepStrictEqual(r.terms.verbs, [...DEFAULT_API_COVERAGE_TERMS.verbs]); + assert.deepStrictEqual(r.terms.nouns, [...DEFAULT_API_COVERAGE_TERMS.nouns]); + }); + + test('terms override — explicit verbs+nouns replace defaults', () => { + const r = detectApiIntegration('xyzzy the frobninator', { verbs: ['xyzzy'], nouns: ['frobninator'] }); + assert.deepStrictEqual(r.terms.verbs, ['xyzzy']); + assert.deepStrictEqual(r.terms.nouns, ['frobninator']); + assert.strictEqual(r.detected, true); + }); + + // ── Positive: compound verb+noun (acceptance #1 trigger) ───────────────── + for (const scope of [ + 'Integrate the Stripe API for payment processing', + 'Wrap the GitHub GraphQL API for issue triage', + 'Connect to the SendGrid REST endpoint for transactional email', + 'Consume the billing service over gRPC', + 'Wire up the Slack webhook for deploy notifications', + 'Onboard the Twilio SDK for SMS', + 'integrate oauth for login', + ]) { + test(`POSITIVE fires on: "${scope}"`, () => { + const r = detectApiIntegration(scope); + assert.strictEqual(r.detected, true, `expected detection for: ${scope}`); + assert.ok(r.signals.length > 0); + assert.ok(r.signals.every((s) => s.snippet.length > 0)); + }); + } + + // ── Positive: explicit API|SDK surface (no verb needed) ──────── + for (const scope of ['Add a Spotify API client', 'Ship the Notion SDK helper']) { + test(`POSITIVE (surface) fires on: "${scope}"`, () => { + const r = detectApiIntegration(scope); + assert.strictEqual(r.detected, true); + assert.ok(r.signals.some((s) => s.verb === '(surface)')); + }); + } + + // ── Negative: false-positive guards (acceptance #4 — the crux) ─────────── + // Each of these is a phase that is NOT an external-API integration. The + // detector must stay silent. The "public API of UserController" case is the + // canonical FP trap: "api" is present but there is no integration verb. + for (const [label, scope] of [ + ['internal API mention', 'The public API of the UserController should accept pagination params'], + ['refactor', 'Refactor the authentication module to use bcrypt'], + ['feature toggle', 'Add a dark mode toggle to the settings page'], + ['bug fix', 'Fix the off-by-one error in the pagination helper'], + ['docs', 'Update the README to document the config options'], + ['internal client code', 'Add a client-side helper to debounce input'], + ['bare noun no verb', 'We expose a REST-ish JSON shape already'], + ['bare verb no noun', 'We will integrate the new design system tokens'], + ]) { + test(`NEGATIVE does not fire (${label}): "${scope}"`, () => { + const r = detectApiIntegration(scope); + assert.strictEqual(r.detected, false, `unexpected detection for [${label}]: ${scope}`); + }); + } + + test('fenced code blocks are stripped — trigger inside a code fence does not fire', () => { + const scope = [ + 'Refactor the helpers.', + '', + '```bash', + '# integrate the Stripe API (example command in docs)', + '```', + '', + 'No integration in this phase.', + ].join('\n'); + assert.strictEqual(detectApiIntegration(scope).detected, false); + }); + + // ── H1 fix (#1562 code review): capitalized sentence starters must not fire + // the API/SDK surface rule. These are common English, not services. + for (const [label, scope] of [ + ['The API', 'The API documentation needs updating.'], + ['An SDK', 'An SDK is already present in the repo.'], + ['Our REST', 'Our REST endpoints return JSON.'], + ['This GraphQL', 'This GraphQL schema is internal.'], + ['New API', 'New API surface was added by the refactor.'], + ]) { + test(`NEGATIVE (stopword) does not fire (${label}): "${scope}"`, () => { + assert.strictEqual(detectApiIntegration(scope).detected, false); + }); + } + + test('compound signal fires when verb and noun are on the same line only', () => { + const sameLine = 'Phase A integrates things.\nLater we mention an api.'; + const splitLine = 'Phase A integrates things.\nLater we mention an api here too.'; + assert.strictEqual(detectApiIntegration(sameLine).detected, false); + assert.strictEqual(detectApiIntegration(splitLine).detected, false); + assert.strictEqual(detectApiIntegration('integrates the api').detected, true); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Matrix parse / validate / render +// ────────────────────────────────────────────────────────────────────────────── + +describe('coverage matrix — parse / validate (#1562 acceptance #2)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + throw new Error(`Could not require ${MODULE_PATH}. Run "npm run build:lib". Underlying: ${err.message}`); + } + const { parseCoverageMatrix, validateCoverageMatrix, renderCoverageMatrix } = mod; + + test('parse markdown table — header + 2 rows', () => { + const md = [ + '| capability | decision | reason |', + '|---|---|---|', + '| search | INTEGRATE | |', + '| playlists | OPT-OUT | not needed yet |', + ].join('\n'); + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'table'); + assert.strictEqual(p.rows.length, 2); + assert.strictEqual(p.rows[0].capability, 'search'); + assert.strictEqual(p.rows[0].decision, 'INTEGRATE'); + assert.strictEqual(p.rows[1].decision, 'OPT-OUT'); + assert.strictEqual(p.rows[1].reason, 'not needed yet'); + assert.strictEqual(p.errors.length, 0); + }); + + test('parse fenced ```coverage JSON block', () => { + const md = [ + 'Some prose.', + '', + '```coverage', + '[{"capability":"search","decision":"INTEGRATE","reason":""},', + ' {"capability":"skip","decision":"OPT-OUT","reason":"out of scope"}]', + '```', + ].join('\n'); + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'json'); + assert.strictEqual(p.rows.length, 2); + assert.strictEqual(p.errors.length, 0); + }); + + test('parse empty / non-matrix input → format none, no rows, no errors', () => { + const p = parseCoverageMatrix('# Notes\n\nNothing here.'); + assert.strictEqual(p.format, 'none'); + assert.strictEqual(p.rows.length, 0); + assert.strictEqual(p.errors.length, 0); + }); + + test('parse non-string → empty result, no throw', () => { + const p = parseCoverageMatrix(undefined); + assert.strictEqual(p.rows.length, 0); + }); + + test('parse rejects malformed fenced JSON with an error', () => { + const md = '```coverage\n{not json}\n```'; + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'json'); + assert.ok(p.errors.length > 0); + assert.strictEqual(p.rows.length, 0); + }); + + // ── validate: boundaries 0 / 1 / 2 rows (limit-1, limit, limit+1) ──────── + test('validate — empty matrix is invalid (acceptance #1: surface must be enumerated)', () => { + const v = validateCoverageMatrix('| capability | decision | reason |\n|---|---|---|'); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /empty/i.test(e))); + assert.strictEqual(v.counts.surface, 0); + }); + + test('validate — single INTEGRATE row is valid (limit)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| search | INTEGRATE | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, 1); + assert.strictEqual(v.counts.integrate, 1); + assert.strictEqual(v.counts.optout, 0); + }); + + test('validate — two rows valid (limit+1)', () => { + const md = [ + '| capability | decision | reason |', + '|---|---|---|', + '| search | INTEGRATE | |', + '| playlists | OPT-OUT | not needed |', + ].join('\n'); + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, 2); + assert.strictEqual(v.counts.optout, 1); + }); + + // ── acceptance #2: every OPT-OUT must carry a reason ───────────────────── + test('validate — OPT-OUT without reason is INVALID (acceptance #2)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| skip | OPT-OUT | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /missing reason/i.test(e))); + }); + + test('validate — OPT-OUT with one-char reason is valid (boundary)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| skip | OPT-OUT | x |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, true); + }); + + test('validate — duplicate capability is invalid (matrix is a set of decisions)', () => { + const md = [ + '| capability | decision | reason |', + '|---|---|---|', + '| search | INTEGRATE | |', + '| Search | OPT-OUT | dup |', + ].join('\n'); + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /duplicate/i.test(e))); + }); + + test('validate — empty capability name is invalid', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| | INTEGRATE | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /empty capability/i.test(e))); + }); + + // ── review-fix coverage: parser robustness (#1562 code review M1/M2/L1/L2) ── + test('validate — invalid decision cell in a table row is an error, not silently dropped (M1)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| x | INTEGRAT | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /not in \{INTEGRATE, OPT-OUT\}/i.test(e))); + }); + + test('validate — a pipe in a cell adds extra columns → invalid (M2)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| x | OPT-OUT | a|b |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /columns|pipe/i.test(e))); + }); + + test('validate — a pipe in a JSON-fence reason → invalid (M2)', () => { + const md = '```coverage\n[{"capability":"x","decision":"OPT-OUT","reason":"a|b"}]\n```'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + }); + + test('validate — capability over the length cap → invalid (S3 bound)', () => { + const longCap = 'x'.repeat(81); + const md = `| capability | decision | reason |\n|---|---|---|\n| ${longCap} | INTEGRATE | |`; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /exceeds/i.test(e))); + }); + + test('parse — case-insensitive ```Coverage fence is accepted (L1)', () => { + const md = '```Coverage\n[{"capability":"a","decision":"INTEGRATE","reason":""}]\n```'; + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'json'); + assert.strictEqual(p.rows.length, 1); + }); + + test('parse — a single-dash cell is not mistaken for a separator (L2)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| - | INTEGRATE | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.counts.surface, 1); + assert.ok(v.valid, 'a capability named "-" is legal'); + }); + + // ── render round-trip (manual) ─────────────────────────────────────────── + test('render → parse round-trips a valid matrix', () => { + const rows = [ + { capability: 'search', decision: 'INTEGRATE', reason: '' }, + { capability: 'playlists', decision: 'OPT-OUT', reason: 'not needed yet' }, + ]; + const rendered = renderCoverageMatrix(rows); + const v = validateCoverageMatrix(rendered); + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, 2); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Property test: parse/render bijectivity (RULESET.TESTS property-based) +// ────────────────────────────────────────────────────────────────────────────── + +describe('coverage matrix — parse/render bijection (fast-check)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + throw new Error(`Could not require ${MODULE_PATH}. Run "npm run build:lib". Underlying: ${err.message}`); + } + const { renderCoverageMatrix, validateCoverageMatrix } = mod; + + test('any valid row set renders and re-validates to the same counts', () => { + const capabilityGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/); + const rowGen = fc.record({ + capability: capabilityGen, + decision: fc.constantFrom('INTEGRATE', 'OPT-OUT'), + // Reasons are short prose (e.g. "not needed yet"). The matrix is a + // markdown table, so cell text is format-safe: no pipes / newlines. + reason: fc.stringMatching(/^[a-z0-9 ,.\-!?]{0,20}$/), + }); + // OPT-OUT rows must carry a non-empty reason for the round-trip to validate. + const validRowGen = rowGen.map((r) => + r.decision === 'OPT-OUT' && r.reason.trim() === '' + ? { ...r, reason: 'because' } + : { ...r, reason: r.reason.trim() } + ); + const matrixGen = fc.uniqueArray(validRowGen, { + minLength: 1, + maxLength: 8, + selector: (r) => r.capability.toLowerCase(), + }); + fc.assert( + fc.property(matrixGen, (rows) => { + const rendered = renderCoverageMatrix(rows); + const v = validateCoverageMatrix(rendered); + // Injected reason guarantees validity; any invalid result is a parser bug. + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, rows.length); + return true; + }), + { numRuns: 100 } + ); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// CLI entry point (STDIN → exit codes mirror grep, like assumption-delta) +// ────────────────────────────────────────────────────────────────────────────── + +describe('api-coverage CLI — STDIN + exit codes', () => { + const CLI = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'api-coverage.cjs'); + + function runCli(stdin) { + const r = spawnSync(process.execPath, [CLI, '--json'], { + input: stdin, + encoding: 'utf-8', + timeout: 15000, + }); + return { exitCode: r.status, stdout: r.stdout, stderr: r.stderr }; + } + + test('exit 0 + JSON IR when integration detected', () => { + const r = runCli('Integrate the Stripe API for payments'); + assert.strictEqual(r.exitCode, 0); + const body = JSON.parse(r.stdout); + assert.strictEqual(body.detected, true); + assert.ok(Array.isArray(body.signals)); + }); + + test('exit 1 when no integration', () => { + const r = runCli('Refactor the login helper'); + assert.strictEqual(r.exitCode, 1); + }); +}); diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index 99936372f..9ff819c05 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -81,6 +81,7 @@ describe('config-field-docs', () => { verifier: 'workflow.verifier', nyquist_validation: 'workflow.nyquist_validation', ai_integration_phase: 'workflow.ai_integration_phase', + api_coverage_gate: 'workflow.api_coverage_gate', text_mode: 'workflow.text_mode', subagent_timeout: 'workflow.subagent_timeout', branching_strategy: 'git.branching_strategy', diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index cb9ae5c8d..e6c8252c3 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "e176817364a7cbf4", "gsd-core/references/autonomous-smart-discuss.md": "efd80aca449032ad", "gsd-core/references/checkpoints.md": "2de680837faa9752", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "b0e55e9d585f90c1", + "gsd-core/references/planning-config.md": "2e2f418328e52f6a", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "2c58df5e21c41c31", "gsd-core/workflows/validate-phase.md": "6c0ab739d15709fa", "gsd-core/workflows/verify-phase.md": "0eefbb6bb1b0bed6", - "gsd-core/workflows/verify-work.md": "be699ed7920f61b0", + "gsd-core/workflows/verify-work.md": "59276d94999ee022", "hooks/gsd-check-update-worker.js": "fa301e6366270d5f", "hooks/gsd-check-update.js": "4617a98bf529e4c3", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index d4f58026c..7785d1737 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "fd160e13f8b7e83c", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "hooks/gsd-check-update-worker.js": "cc1ef5f840f9dfc9", "hooks/gsd-check-update.js": "7b3a7983d5f1f5d3", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 9127cacfc..2be2052f2 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -51,6 +51,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -107,7 +108,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "99c46b7d318adf1a", + "gsd-core/references/planning-config.md": "b025429fc72f9285", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -307,7 +308,7 @@ "gsd-core/workflows/update.md": "f9e7d8a760d0d3c8", "gsd-core/workflows/validate-phase.md": "2ac231dc541441c2", "gsd-core/workflows/verify-phase.md": "e0957e153788a222", - "gsd-core/workflows/verify-work.md": "efe57bdbbb3af03f", + "gsd-core/workflows/verify-work.md": "c8ffee621e7319de", "hooks/gsd-check-update-worker.js": "a530efdb5fdc0da3", "hooks/gsd-check-update.js": "25cde66a12d6b886", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index bd83660d1..f3841bb20 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -55,6 +55,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "59f782556e4e611f", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "9a7ba3a17ece1698", @@ -111,7 +112,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "a0dc2dddb953b22c", + "gsd-core/references/planning-config.md": "5a40b2934db6a321", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -311,7 +312,7 @@ "gsd-core/workflows/update.md": "165beec33490bd28", "gsd-core/workflows/validate-phase.md": "5b4ae14c87859bd2", "gsd-core/workflows/verify-phase.md": "a4f918c92c927268", - "gsd-core/workflows/verify-work.md": "6fa216623778c541", + "gsd-core/workflows/verify-work.md": "3fb282f2bce34a79", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 71d7569c7..df78da0b6 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "5ff1f77222977648", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "hooks/gsd-check-update-worker.js": "bdc9324a2f080ddd", "hooks/gsd-check-update.js": "b7669f605631e506", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index ad2b46d1f..a6e01217e 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -87,6 +87,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "524382216a8e713f", "gsd-core/references/artifact-types.md": "3218cafb0c92dc32", "gsd-core/references/autonomous-smart-discuss.md": "4156025334411073", "gsd-core/references/checkpoints.md": "9feb961f644afa96", @@ -143,7 +144,7 @@ "gsd-core/references/planner-reviews.md": "7889bfa28e82156b", "gsd-core/references/planner-revision.md": "2ebf1a714d1ec4bf", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "40d4abb1ed0f3723", + "gsd-core/references/planning-config.md": "b01856d7a63e73a2", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -343,7 +344,7 @@ "gsd-core/workflows/update.md": "5c35c0ec0f462ea6", "gsd-core/workflows/validate-phase.md": "020201a41049679f", "gsd-core/workflows/verify-phase.md": "2e12c3cb97a9122a", - "gsd-core/workflows/verify-work.md": "69e27f6f419d0bba", + "gsd-core/workflows/verify-work.md": "5b348e73fc0d0829", "hooks/gsd-check-update.js": "ef48957eb6ac6a10", "hooks/gsd-context-monitor.js": "76fecaaa2babd6c1", "scripts/changeset/README.md": "86ff89331dfd94b2", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 0e194d4b8..060f81cd9 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -53,6 +53,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "f992de8b2b1a4420", "gsd-core/references/autonomous-smart-discuss.md": "efd80aca449032ad", "gsd-core/references/checkpoints.md": "c70b323dcb1583d5", @@ -109,7 +110,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "fd81dd276828eab4", + "gsd-core/references/planning-config.md": "309a566a0e13e43e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -309,7 +310,7 @@ "gsd-core/workflows/update.md": "f444a7cfcd246cfb", "gsd-core/workflows/validate-phase.md": "2f705775a4b76d42", "gsd-core/workflows/verify-phase.md": "5c72780e34214e27", - "gsd-core/workflows/verify-work.md": "58e9b1b16f773b53", + "gsd-core/workflows/verify-work.md": "c966971a3cbd1d71", "hooks/gsd-session.json": "0a462834f2a28fee", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index dcca3bbc3..c618c1846 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "3d62d178004db5cc", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", "gsd-core/references/checkpoints.md": "3001eeccb319781b", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "8af05de88771e9f4", + "gsd-core/references/planning-config.md": "a3f1b4aca291db59", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "23e294ba707c3580", "gsd-core/workflows/validate-phase.md": "2df0c6e298a5f249", "gsd-core/workflows/verify-phase.md": "e0957e153788a222", - "gsd-core/workflows/verify-work.md": "145596b2542c457a", + "gsd-core/workflows/verify-work.md": "e7e7e900c4874490", "hooks/gsd-cursor-post-tool.js": "019d503aee8b4a3f", "hooks/gsd-cursor-session-start.js": "c6e04ed597ea7020", "scripts/changeset/README.md": "86ff89331dfd94b2", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index a76235b8d..f8013837a 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "892f086e846cd8e4", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "db8a7425ed808e24", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "1358a14bded944f5", + "gsd-core/references/planning-config.md": "831d626c214d92e4", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "7499bb4cb2a3ce6f", "gsd-core/workflows/validate-phase.md": "75e8971d3981d06b", "gsd-core/workflows/verify-phase.md": "5c8d1305b47fbef4", - "gsd-core/workflows/verify-work.md": "e56e07475d5eb51e", + "gsd-core/workflows/verify-work.md": "7a9c9541d2d73fdc", "hooks/gsd-check-update-worker.js": "7989cc2bedd1138d", "hooks/gsd-check-update.js": "25f5ad726f76fc11", "hooks/gsd-config-reload.js": "880b696458e85e9b", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 62d6de451..f3596be07 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "3986d58011bf9006", "gsd-core/references/checkpoints.md": "9feb961f644afa96", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "aad463ddda23dbaf", + "gsd-core/references/planning-config.md": "37ab69107a2f7dc6", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "07dc2fba78ad1865", "gsd-core/workflows/validate-phase.md": "557e3251e3b9349a", "gsd-core/workflows/verify-phase.md": "e0957e153788a222", - "gsd-core/workflows/verify-work.md": "b68ac37f6301a3b5", + "gsd-core/workflows/verify-work.md": "9fc717845828c6e3", "kilo.json": "13151e97ff23c1aa", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 795b3c198..2b3f8fa6b 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -88,6 +88,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -144,7 +145,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -344,7 +345,7 @@ "gsd-core/workflows/update.md": "6718e0632bba26ca", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 1da46755a..ed0c3e395 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "218c55caf8aff6df", "gsd-core/references/autonomous-smart-discuss.md": "3986d58011bf9006", "gsd-core/references/checkpoints.md": "9feb961f644afa96", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "aad463ddda23dbaf", + "gsd-core/references/planning-config.md": "37ab69107a2f7dc6", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "71b6cd852f38b4bc", "gsd-core/workflows/validate-phase.md": "abcdbc1b56780565", "gsd-core/workflows/verify-phase.md": "126be1d026900102", - "gsd-core/workflows/verify-work.md": "f0d205568abfaf74", + "gsd-core/workflows/verify-work.md": "ae07b3fa8b6f864e", "hooks/gsd-check-update-worker.js": "385fb7c67810baf6", "hooks/gsd-check-update.js": "4549451414ffa7d7", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index bf6e3e38e..6f24ee22f 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "be09755fed7ad856", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "046171320f816346", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "0037314fe38ca55c", + "gsd-core/references/planning-config.md": "4c6de9b6d66aca73", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "b34cb866152d4de3", "gsd-core/workflows/validate-phase.md": "e989cbaa4228564c", "gsd-core/workflows/verify-phase.md": "0dc52b9e629a5f5a", - "gsd-core/workflows/verify-work.md": "b5afb65fdf311301", + "gsd-core/workflows/verify-work.md": "3355ddc14f052fff", "hooks/gsd-check-update-worker.js": "4bb354044e0dff91", "hooks/gsd-check-update.js": "d2065cb3e725a42a", "hooks/gsd-config-reload.js": "4f52b8a0120bb1b8", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 5aecb31d3..7c07b71ab 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "18d1ff7c7fa0bab1", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "4b8c645ffa695067", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "e47cdb2337e10dac", + "gsd-core/references/planning-config.md": "a686c1363ae626c5", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "1f935251fca1f276", "gsd-core/workflows/validate-phase.md": "1c0ebe56d96a14d1", "gsd-core/workflows/verify-phase.md": "a151ed36eb51813b", - "gsd-core/workflows/verify-work.md": "d3a0970205acc6a5", + "gsd-core/workflows/verify-work.md": "dde2a42a56c27c4e", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 71f434e76..4993ab62d 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "849977da771f2b06", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", "gsd-core/references/checkpoints.md": "808e4fcaa2fda15c", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "aafbfc62a3bb84b1", + "gsd-core/references/planning-config.md": "1fc70920778d7c8e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "79aaf4b8f1f83045", "gsd-core/workflows/validate-phase.md": "2db47bf5547d7b9d", "gsd-core/workflows/verify-phase.md": "f961cdb3ef03ff05", - "gsd-core/workflows/verify-work.md": "5ad63a5edfb6acac", + "gsd-core/workflows/verify-work.md": "cce8ae44b30957f1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 7d18f6001..5c044e9eb 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "dc580dee13f881a6", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/issue-2045-third-party-skills-surface.test.cjs b/tests/issue-2045-third-party-skills-surface.test.cjs index 09917e620..3e5ccd1cb 100644 --- a/tests/issue-2045-third-party-skills-surface.test.cjs +++ b/tests/issue-2045-third-party-skills-surface.test.cjs @@ -29,7 +29,7 @@ * AC5: first-party caps unaffected; writer still rejects truly-unknown ids. */ -const { describe, test, before, after } = require('node:test'); +const { describe, test, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); diff --git a/tests/loop-hooks-empty-points-e2e.test.cjs b/tests/loop-hooks-empty-points-e2e.test.cjs index 7099c999b..6c28f4f27 100644 --- a/tests/loop-hooks-empty-points-e2e.test.cjs +++ b/tests/loop-hooks-empty-points-e2e.test.cjs @@ -5,7 +5,10 @@ * Hook points tested: discuss:pre, discuss:post, execute:pre, execute:wave:pre, * verify:pre, ship:post * - * All 6 points have zero hooks in the real registry by design. + * 5 of these points have zero hooks in the real registry by design. + * verify:pre graduated out of the empty set in #1562 (it now carries the + * ai-integration `api-coverage.verify-pre` blocking gate); SECTION 5 pins the + * new contract + retains synthetic extension-point-mechanics coverage. * Tests pin: exact envelope shape, placeholder string contract (Hyrum's Law), * resolver-filter mechanics (schema default / config-override / capabilityStatesById), * CLI contract (missing-arg, invalid-point), and Postel-leniency (malformed config). @@ -481,8 +484,13 @@ describe('execute:wave:pre — real registry empty-resolution + synthetic mechan // ───────────────────────────────────────────────────────────────────────────── // SECTION 5: verify:pre // ───────────────────────────────────────────────────────────────────────────── +// NOTE: verify:pre was an empty extension point until #1562 added the +// ai-integration `api-coverage.verify-pre` blocking gate (default-on, opt-out +// via workflow.api_coverage_gate=false). The real-registry assertions below pin +// the NEW contract; the synthetic BVA tests retain extension-point-mechanics +// coverage. Empty-point coverage for the other 5 points is unaffected. -describe('verify:pre — real registry empty-resolution + synthetic extension-point readiness', () => { +describe('verify:pre — real registry carries the api-coverage gate (#1562); synthetic mechanics', () => { let tmpEmptyProjectDir; let tmpProjectDirAllOn; before(() => { @@ -494,29 +502,47 @@ describe('verify:pre — real registry empty-resolution + synthetic extension-po cleanup(tmpProjectDirAllOn); }); - it('[empty-resolution] verify:pre with real registry and no config yields empty activeHooks and exact placeholder (Gall\'s Law)', () => { + it('[default-on] verify:pre with real registry and no config yields the api-coverage blocking gate (full-coverage-by-default)', () => { const resolved = resolveLoopHooks({ point: 'verify:pre', registry: realRegistry, config: {} }); - assert.strictEqual(resolved.activeHooks.length, 0); - assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:pre._'); + const gate = resolved.activeHooks.find((h) => h.capId === 'ai-integration' && h.kind === 'gate'); + assert.ok(gate, 'ai-integration api-coverage gate must register at verify:pre by default'); + assert.strictEqual(gate.blocking, true); + assert.strictEqual(gate.check.query, 'api-coverage.verify-pre'); }); - it('[happy] verify:pre E2E subprocess returns well-formed 3-key JSON envelope with empty activeHooks (Hyrum\'s Law contract pin)', () => { + it('[happy] verify:pre E2E subprocess returns a well-formed envelope carrying the gate (Hyrum\'s Law contract pin)', () => { const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', tmpEmptyProjectDir, '--raw'], tmpEmptyProjectDir); assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`); const envelope = JSON.parse(result.stdout.trim()); assert.strictEqual(envelope.point, 'verify:pre'); - assert.deepEqual(envelope.activeHooks, []); - assert.strictEqual(envelope.rendered, '_No active hooks at verify:pre._'); + assert.ok(Array.isArray(envelope.activeHooks)); + assert.ok(envelope.activeHooks.some((h) => h.capId === 'ai-integration' && h.kind === 'gate')); assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']); }); - it('[negative] verify:pre with all capability config keys set to true still yields empty activeHooks — no leakage from other points', () => { + it('[opt-out] verify:pre with workflow.api_coverage_gate=false yields NO ai-integration gate — config opt-out empties the point', () => { + const resolved = resolveLoopHooks({ + point: 'verify:pre', + registry: realRegistry, + config: { workflow: { api_coverage_gate: false } }, + }); + assert.strictEqual( + resolved.activeHooks.find((h) => h.capId === 'ai-integration'), + undefined, + 'opting out api_coverage_gate must remove the gate from verify:pre' + ); + }); + + it('[negative] verify:pre with unrelated capability keys on does not bleed non-verify:pre hooks into the point', () => { const resolved = resolveLoopHooks({ point: 'verify:pre', registry: realRegistry, config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }, }); - assert.strictEqual(resolved.activeHooks.length, 0, 'UI and other capabilities must not bleed through to verify:pre'); + // The only verify:pre hook is the api-coverage gate; UI/other hooks must not bleed in. + for (const h of resolved.activeHooks) { + assert.notStrictEqual(h.capId, 'ui', 'UI capability must not bleed through to verify:pre'); + } }); it('[bva] Synthetic step at verify:pre with configSchema default=true fires correctly — extension point readiness', () => { @@ -547,7 +573,10 @@ describe('verify:pre — real registry empty-resolution + synthetic extension-po const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', malformedDir, '--raw'], malformedDir); assert.strictEqual(result.status, 0, `must not crash on malformed config. stderr: ${result.stderr}`); const envelope = JSON.parse(result.stdout.trim()); - assert.deepEqual(envelope.activeHooks, []); + // Malformed config degrades to defaults (gate default-on); the contract + // here is leniency (exit 0, well-formed envelope), not emptiness. + assert.strictEqual(envelope.point, 'verify:pre'); + assert.ok(Array.isArray(envelope.activeHooks)); } finally { cleanup(malformedDir); } @@ -720,13 +749,12 @@ describe('CLI contract — missing/invalid point argument (shared across all 6 e // SECTION 8: Parametric empty-point sweep across all 6 points (E2E regression guard) // ───────────────────────────────────────────────────────────────────────────── -describe('Parametric E2E sweep — 5 empty points return correct envelope shape via real registry (ship:post excluded — mempalace registers 1 step there)', () => { +describe('Parametric E2E sweep — 4 empty points return correct envelope shape via real registry (ship:post excluded — mempalace registers 1 step there; verify:pre excluded since #1562 added the api-coverage gate)', () => { const EMPTY_POINTS = [ 'discuss:pre', 'discuss:post', 'execute:pre', 'execute:wave:pre', - 'verify:pre', ]; for (const point of EMPTY_POINTS) { diff --git a/tests/plan-pre-hook-e2e.test.cjs b/tests/plan-pre-hook-e2e.test.cjs index 41b02833f..0e0d75600 100644 --- a/tests/plan-pre-hook-e2e.test.cjs +++ b/tests/plan-pre-hook-e2e.test.cjs @@ -246,6 +246,7 @@ describe('plan:pre all-off — empty resolution', () => { tmpDir = makeProject({ workflow: { ai_integration_phase: false, + api_coverage_gate: false, tdd_mode: false, security_enforcement: false, ui_phase: false, diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 945f3c59f..d389e7147 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -89,5 +89,5 @@ "update.md": 20914, "validate-phase.md": 10789, "verify-phase.md": 40923, - "verify-work.md": 38267 + "verify-work.md": 40247 }