feat(ai-integration): API-coverage verify:pre gate (#1562)
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 + <Service> 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
This commit is contained in:
5
.changeset/1562-api-coverage-gate.md
Normal file
5
.changeset/1562-api-coverage-gate.md
Normal file
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
105
capabilities/ai-integration/fragments/api-coverage-plan-pre.md
Normal file
105
capabilities/ai-integration/fragments/api-coverage-plan-pre.md
Normal file
@@ -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 |
|
||||
|---|---|---|
|
||||
| `<capability-id>` | `INTEGRATE` \| `OPT-OUT` | `<one-line reason if 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 — <service>
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| search | INTEGRATE | |
|
||||
| playlists | INTEGRATE | |
|
||||
| skip | OPT-OUT | not needed yet — tracked for follow-up phase |
|
||||
```
|
||||
|
||||
A fenced ` ```coverage ` JSON block is also accepted for machine-generated
|
||||
matrices; the markdown table is preferred (human-editable, diff-friendly).
|
||||
|
||||
## The seal-time gate
|
||||
|
||||
This checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate
|
||||
runs `check api-coverage.verify-pre <phase-dir>`:
|
||||
|
||||
- If `COVERAGE.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`.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 + `<Service> 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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
466
gsd-core/bin/lib/api-coverage.cjs
Normal file
466
gsd-core/bin/lib/api-coverage.cjs
Normal file
@@ -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 "<Service> 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}`;
|
||||
}
|
||||
/** `<Service> API` / `<Service> 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 `<Service> 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 <Service> 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);
|
||||
});
|
||||
}
|
||||
@@ -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| `<capability-id>` | `INTEGRATE` \\| `OPT-OUT` | `<one-line reason if 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 — <service>\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 <phase-dir>`:\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| `<capability-id>` | `INTEGRATE` \\| `OPT-OUT` | `<one-line reason if 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 — <service>\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 <phase-dir>`:\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",
|
||||
|
||||
104
gsd-core/references/api-coverage.md
Normal file
104
gsd-core/references/api-coverage.md
Normal file
@@ -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 `<Service>
|
||||
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 <phase-dir>` 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 — <service>
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| search | INTEGRATE | |
|
||||
| playlists | INTEGRATE | |
|
||||
| skip | OPT-OUT | not needed yet — tracked for follow-up phase |
|
||||
```
|
||||
|
||||
- **`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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -58,6 +58,44 @@ MVP_MODE=$(gsd_run query phase.mvp-mode "${phase_number}" ${GSD_WS} --pick activ
|
||||
```
|
||||
</step>
|
||||
|
||||
<step name="verify_pre_hooks">
|
||||
**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 '<hook.check.predicate as JSON>' --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.
|
||||
</step>
|
||||
|
||||
<step name="check_active_session">
|
||||
**First: Check for active UAT sessions**
|
||||
|
||||
|
||||
514
src/api-coverage.cts
Normal file
514
src/api-coverage.cts
Normal file
@@ -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 "<Service> 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<ApiCoverageTermSet> = {
|
||||
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<string>();
|
||||
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>): 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}`;
|
||||
}
|
||||
|
||||
/** `<Service> API` / `<Service> 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 `<Service> API|SDK|REST|GraphQL` surface appears.
|
||||
*
|
||||
* Non-string inputs degrade to `{ detected: false }` without throwing.
|
||||
*/
|
||||
export function detectApiIntegration(
|
||||
text: unknown,
|
||||
terms?: Partial<ApiCoverageTermSet>,
|
||||
): 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<string>();
|
||||
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 <Service> 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<CoverageDecision>(['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<string, unknown>;
|
||||
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<string>();
|
||||
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<ApiCoverageTermSet> | 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);
|
||||
});
|
||||
}
|
||||
@@ -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 <phase-dir>
|
||||
* 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 <phase-dir-or-token>',
|
||||
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 = {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -246,6 +246,7 @@ function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<str
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
ai_integration_phase: true,
|
||||
api_coverage_gate: true,
|
||||
human_verify_mode: 'end-of-phase',
|
||||
context_guard_mode: 'warn',
|
||||
text_mode: false,
|
||||
|
||||
273
tests/api-coverage-gate-e2e.test.cjs
Normal file
273
tests/api-coverage-gate-e2e.test.cjs
Normal file
@@ -0,0 +1,273 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* E2E capability-wiring tests for the API-coverage gate (#1562).
|
||||
*
|
||||
* Drives the real CLI subprocess (`loop render-hooks verify:pre` and
|
||||
* `check api-coverage.verify-pre`) against temp projects to prove:
|
||||
* - the gate is data-driven (activates/deactivates by config) — acceptance #5
|
||||
* - the seal contract (block / pass) — acceptance #1, #2, #4
|
||||
* - the matrix persists on disk and is read at seal time — acceptance #6
|
||||
*
|
||||
* CONTENT/E2E only: every test drives a real CLI subprocess. No readFileSync
|
||||
* source-grep. Genuine assertions: each case asserts the SPECIFIC differing
|
||||
* value (block true/false, capId presence), not a count.
|
||||
*/
|
||||
|
||||
const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
.map((t) => 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
410
tests/api-coverage.test.cjs
Normal file
410
tests/api-coverage.test.cjs
Normal file
@@ -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 <Service> 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 <Service> 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,5 +89,5 @@
|
||||
"update.md": 20914,
|
||||
"validate-phase.md": 10789,
|
||||
"verify-phase.md": 40923,
|
||||
"verify-work.md": 38267
|
||||
"verify-work.md": 40247
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user