* feat(#3907): gates report no-input instead of asserting a verdict they never reached The three stdin-reading gates bound 2 to a stdin read error only, with no arm for stdin closed at zero bytes - so empty input flowed into the detector, found nothing, and exited 1, which each module's own comment defines as a negative verdict. An unset PHASE_SECTION made the UI gate assert the phase has no UI. Empty and whitespace-only input now exit NO_INPUT, and a read error exits UNAVAILABLE rather than a locally-invented 2, both resolved through the registry and delivered by terminateNow. The exit code was only half of it: under --json the same input emitted {detected:false}, byte-identical to the fabricated payload #3909 exists to fix, and the blocking coverage gate reads that payload. Empty input now emits the in-tree {skipped:true,reason} form with no detected key at all. teams-status is excluded: it never reads stdin and has no invented 2, so the four-module framing in the issue and ADR is wrong. The dead root bin/lib/ui-safety-gate.cjs is deleted - no installer reference, no workflow invocation, and the live fallback chains are for other modules. Its removal restores the unit tests to the module that actually ships; they had been asserting the stale copy's two-field shape, which is why it drifted unnoticed. * fix(#3907): drive gate tests through the process seam, and make removed-but-needed basename-precise CONTRIBUTING requires every subprocess go through tests/helpers/process-seam.cjs; two of the three gate suites hand-rolled spawnSync while the third, added in the same change, used runNode correctly for the identical injection case. Converted the blocks this change added, leaving pre-existing ones alone. Deleting one of two files sharing a basename made lint-removed-but-needed report 14 references that were all to the surviving canonical module - the false-positive class its own docstring names. It now matches on the deleted file's full path when a surviving file shares its basename, which is more precise rather than weaker: a genuine full-path reference still fails, and behaviour is unchanged when no basename collides. It immediately caught a docstring on this branch that spelled the deleted path. * test(#3907): update the one existing assertion that pinned the old empty-stdin verdict A pre-existing test asserted exit 1 on empty stdin - the defect this phase removes - and was missed because the change added new blocks without auditing existing ones pinning the old contract. Audited the rest: the other three status-1 assertions in that file all feed real input and are the genuine-negative controls that must keep returning 1, so exactly one was stale. The retired 2 is gone from the describe's contract comment too. * chore(#3907): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
1006 lines
47 KiB
TypeScript
1006 lines
47 KiB
TypeScript
/**
|
||
* 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 and an EXTERNAL-API NOUN in the SAME
|
||
* CLAUSE (#2365 — same-line co-occurrence across unrelated clauses over-fired;
|
||
* the clause boundary, not a word-gap cap, is the relationship test), or an
|
||
* explicit "<Service> API/SDK" phrase naming a real service. Single weak
|
||
* tokens do not fire. This is the issue's "low false-positive trigger" made
|
||
* mechanical.
|
||
* - CODE AND PATHS ARE NOT PROSE. Fenced code blocks and inline code spans are
|
||
* stripped first (markdown-sectionizer seam), and path-shaped tokens
|
||
* (`src/app/api/...`, URLs) are masked, so a trigger term inside code or a
|
||
* first-party route path does not fire (#2365).
|
||
* - NO-INTEGRATION DECLARATION (#2365 acceptance #5). A COVERAGE.md consisting
|
||
* of `No external API integration: <reason>` is a valid, reasoned way for a
|
||
* phase to state that no external surface exists — the alternative to
|
||
* fabricating a matrix row when the detector is overruled by a human.
|
||
* - 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 (ADR-3889 Phase 3, #3907):
|
||
* echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json]
|
||
* exit 0 = integration detected, 1 = none (real input, examined, no signal),
|
||
* NO_INPUT (registry, src/cli-exit.cts) = stdin empty/whitespace-only,
|
||
* UNAVAILABLE (registry) = stdin read failed
|
||
* --json additionally prints the typed IR on stdout (unchanged for 0/1);
|
||
* NO_INPUT/UNAVAILABLE print {skipped:true, reason:"no_input"|"stdin_error"}
|
||
*/
|
||
|
||
import { stripFencedCode, scanInlineCodeSpans, extractFencedBlock } from './markdown-sectionizer.cjs';
|
||
import { escapeRegex } from './pattern.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 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). */
|
||
// Service-name length is bounded ({1,40}) so a hostile "A-A-A-…-A-x" run cannot
|
||
// drive the greedy group into O(n^2) backtracking (#2365 review). Nearly all
|
||
// vendor names fit; a >41-char service token before API/SDK would be missed by
|
||
// this surface path (it would still fire via the compound verb+noun rule) —
|
||
// an accepted bound.
|
||
const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,40})\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',
|
||
]);
|
||
|
||
/** #2365 — the detector is FAIL-CLOSED: it leans toward detecting, because a
|
||
* false positive is cheaply dismissed by a one-line COVERAGE.md "no external
|
||
* API integration" declaration, whereas a false NEGATIVE silently lets a real
|
||
* external-API phase past a BLOCKING gate. So the only prose the detector
|
||
* actively suppresses is the classes that are unambiguously NOT external
|
||
* integration: first-party route paths, verb/noun in unrelated clauses, and
|
||
* descriptive/protocol "<Word> API" prose with no named service.
|
||
*
|
||
* CLAUSE_BOUNDARY_RE: a verb and a noun form ONE compound action only inside
|
||
* one grammatical clause — sentence punctuation and table-cell walls (`|`)
|
||
* end a clause. `-` is deliberately absent (it would split hyphenated words).
|
||
* There is deliberately NO word-gap cap inside a clause: a cap cannot separate
|
||
* a genuine long integration clause (F4, 21 words) from a long internal-UI
|
||
* clause (18 words) — the clause boundary is the only sound signal, and the
|
||
* declaration handles the residual false positives. */
|
||
const CLAUSE_BOUNDARY_RE = /[,;:.!?|()—–]/;
|
||
/** Same character class as CLAUSE_BOUNDARY_RE, as a set — for scanning a token's
|
||
* trailing punctuation without an unanchored `[…]+$` regex, whose backtracking
|
||
* is O(n^2) on a long punctuation run (#2365 review). */
|
||
const CLAUSE_BOUNDARY_CHARS = new Set([',', ';', ':', '.', '!', '?', '|', '(', ')', '—', '–']);
|
||
|
||
/* DELIBERATELY NO cross-clause binding. Detection is same-clause only. Binding
|
||
* a verb in one clause to a noun in another ("Integrate Stripe, exposing its
|
||
* endpoints"; "Integrate Stripe; use its endpoints") requires knowing "Stripe"
|
||
* is a vendor and "its" refers to it — a vendor dictionary + coreference, which
|
||
* trek-e's brief rules out in principle. Every lexical cross-clause rule tried
|
||
* (word-gap cap, participle continuation) traded a false negative for a false
|
||
* positive across four review rounds. So a service named ONLY in a clause
|
||
* separate from its API noun, with no explicit `<Service> API` surface, is a
|
||
* DOCUMENTED fail-open limitation — cheaply covered by the COVERAGE.md
|
||
* declaration and rare in real phase prose, which says "integrate the X API". */
|
||
|
||
/** In the `<Service> API|SDK` surface position, these capture words are NOT a
|
||
* named third-party service: locality/scope descriptors ("Internal API",
|
||
* "Public API") and bare protocol names ("REST API", "GraphQL API"). A real
|
||
* vendor name (Stripe, Shopify) is none of these, so rejecting them costs no
|
||
* true positives while killing the descriptive-prose false positives (#2365
|
||
* acceptance #3, review F8). */
|
||
const SURFACE_DESCRIPTOR_WORDS = new Set([
|
||
'internal', 'external', 'public', 'private', 'local', 'in-house', 'first-party',
|
||
'generic', 'shared', 'common', 'legacy', 'rest', 'restful', 'graphql', 'grpc',
|
||
'soap', 'rpc', 'http', 'https', 'json', 'xml',
|
||
]);
|
||
|
||
/** Locality qualifiers that, when they immediately precede a `<Service> API`,
|
||
* mark it as first-party ("internal Payments API") — negative evidence for an
|
||
* EXTERNAL-API surface signal. Only unambiguously-internal words: "external"
|
||
* is deliberately absent (an external API IS external). */
|
||
const INTERNAL_DESCRIPTORS = new Set(['internal', 'in-house', 'local', 'first-party', 'private']);
|
||
|
||
/** #2784: negation suppression. A clause that pairs an integration verb with an
|
||
* API noun but the verb itself is directly negated (e.g. "does not integrate",
|
||
* "integrates no external API") is suppressed. Two windows are checked: a
|
||
* negation qualifier within 2 words directly before the verb, or "no"/"zero"/
|
||
* "none" between the verb and a following noun.
|
||
* KNOWN LIMIT (deliberate, not a bug to fix later): a negation further than 2
|
||
* words before the verb, or a clause where the noun precedes the verb, is NOT
|
||
* suppressed — e.g. "Ships without any API integration." still reports
|
||
* detected:true, because "without" sits outside the verb's 2-word lookback
|
||
* and the noun precedes the verb. This is intentional: detectApiIntegration
|
||
* is fail-closed — an unsuppressed false positive costs a one-line
|
||
* COVERAGE.md declaration, while widening the suppression window risks a
|
||
* false negative that silently lets a real external-API phase past a
|
||
* blocking gate.
|
||
* Hoisted to module scope (#3127 regression fix): this was previously
|
||
* allocated fresh on every source line, which is wasted work on documents
|
||
* with many lines. */
|
||
const NEGATION_QUALIFIERS = new Set([
|
||
'no', 'not', 'without', 'zero', 'neither', 'nor', 'none', "don't", "doesn't", "didn't", "won't", "can't", "cannot",
|
||
]);
|
||
/** Negation tokens checked BETWEEN a verb and a later noun (narrower than
|
||
* NEGATION_QUALIFIERS — "not" and "without" are checked only immediately
|
||
* before the verb, via NEGATION_QUALIFIERS above). */
|
||
const NEGATION_NOUN_TOKENS = new Set(['no', 'zero', 'none']);
|
||
|
||
/** A capitalized compound modifier ("Resolver-only", "Read-only", "E-commerce"
|
||
* — lowercase letter right after the hyphen) is an adjective phrase, not a
|
||
* service name. Real hyphenated services capitalize the second segment
|
||
* ("T-Mobile"). */
|
||
const COMPOUND_MODIFIER_RE = /^[A-Z][A-Za-z0-9]*-[a-z]/;
|
||
|
||
interface TermMatch {
|
||
term: string;
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
interface LineScan {
|
||
/** The line with path-shaped tokens replaced by same-length space padding
|
||
* (offsets preserved for the clause logic). */
|
||
masked: string;
|
||
/** Noun-vocabulary terms found inside NON-LOCAL URLs (`https://api.stripe.com`)
|
||
* — a URL that itself names an API surface is external-dependency evidence,
|
||
* so it still feeds the compound rule even though the URL is masked from
|
||
* plain prose matching. */
|
||
urlNouns: TermMatch[];
|
||
}
|
||
|
||
const URL_TOKEN_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\//i;
|
||
const LOCAL_URL_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\/(?:localhost|127(?:\.\d{1,3}){1,3}|0\.0\.0\.0|\[::1\])(?=[:/?#]|$)/i;
|
||
/** A scheme-less token that STARTS with a dotted hostname whose final label is
|
||
* alphabetic ("api.stripe.com/v1") — a bare external API host. A first-party
|
||
* route path ("src/app/api/…") has no dotted head, and an IP host ("127.1/…")
|
||
* has a numeric final label, so neither matches (#2365 review F2). */
|
||
const DOMAIN_HEAD_RE = /^[([<"'`]*(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?=[:/?#]|$)/i;
|
||
|
||
/** Mask whitespace-delimited tokens with an interior `/` — file paths, framework
|
||
* routes (`src/app/api/...`), URLs. They are references, not integration prose
|
||
* (#2365 root cause 2: `/` counted as a word boundary, so first-party route
|
||
* paths matched the noun vocabulary). Two carve-outs keep genuine signals:
|
||
* - a slashed token whose segments are ALL noun-vocabulary words ("API/SDK",
|
||
* "REST/GraphQL") is prose shorthand, not a path — left unmasked;
|
||
* - a non-local URL is masked, but noun terms inside it are collected as
|
||
* compound-rule evidence (the old detector caught "connect to
|
||
* https://api.stripe.com" via the `api` segment; losing that would
|
||
* fail-open). */
|
||
function scanLineTokens(line: string, nounRe: RegExp | null, nounSet: Set<string>): LineScan {
|
||
const urlNouns: TermMatch[] = [];
|
||
let masked = '';
|
||
const tokenRe = /\S+/g;
|
||
let last = 0;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = tokenRe.exec(line)) !== null) {
|
||
const rawTok = m[0];
|
||
masked += line.slice(last, m.index);
|
||
last = m.index + rawTok.length;
|
||
// Peel trailing clause-boundary punctuation off the token and keep it
|
||
// LITERAL in `masked` — masking it away would erase a clause split and pair
|
||
// unrelated verb/noun across it (#2365 review F6: "…example.com, document…").
|
||
// A backward char scan (not a `[…]+$` regex) keeps this linear.
|
||
let trailLen = 0;
|
||
while (trailLen < rawTok.length && CLAUSE_BOUNDARY_CHARS.has(rawTok[rawTok.length - 1 - trailLen])) {
|
||
trailLen++;
|
||
}
|
||
const trail = trailLen ? rawTok.slice(rawTok.length - trailLen) : '';
|
||
const tok = trailLen ? rawTok.slice(0, rawTok.length - trailLen) : rawTok;
|
||
if (!/\S[\\/]\S/.test(tok)) {
|
||
masked += rawTok;
|
||
continue;
|
||
}
|
||
const segments = tok.split(/[\\/]/).map((s) => s.replace(/[^A-Za-z0-9]/g, ''));
|
||
if (
|
||
segments.every((s) => s.length > 0 && (nounSet.has(s.toLowerCase()) || /^v\d+$/i.test(s))) &&
|
||
segments.some((s) => nounSet.has(s.toLowerCase()))
|
||
) {
|
||
masked += rawTok; // "API/SDK", "API/v2" — noun shorthand, not a path
|
||
continue;
|
||
}
|
||
// A scheme URL or a bare external hostname is an external dependency
|
||
// reference: mask it from prose but keep it as compound-rule evidence. A
|
||
// first-party route path has neither a scheme nor a dotted host, so it is
|
||
// masked WITHOUT contributing nouns (#2365 root cause 2).
|
||
// A non-local URL that NAMES an API vocabulary word ("api.stripe.com/v1")
|
||
// is external-dependency evidence, so its vocab nouns feed the compound
|
||
// rule. We deliberately do NOT treat every path-bearing URL as an endpoint:
|
||
// that fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x") and
|
||
// recreated routine UI-phase false positives (#2365 review). A bare external
|
||
// host that names no vocabulary word ("graph.microsoft.com") and is not
|
||
// written as "<Service> API" is therefore a DOCUMENTED fail-open limitation.
|
||
const isSchemeUrl = URL_TOKEN_RE.test(tok) && !LOCAL_URL_RE.test(tok);
|
||
const isDomainUrl = !URL_TOKEN_RE.test(tok) && DOMAIN_HEAD_RE.test(tok);
|
||
if (nounRe && (isSchemeUrl || isDomainUrl)) {
|
||
for (const f of collectTermMatches(nounRe, tok)) {
|
||
urlNouns.push({ term: f.term, start: m.index, end: m.index + tok.length });
|
||
}
|
||
}
|
||
masked += ' '.repeat(tok.length) + trail;
|
||
}
|
||
masked += line.slice(last);
|
||
return { masked, urlNouns };
|
||
}
|
||
|
||
/** All term matches in a clause, with offsets. `re` must be global with the
|
||
* term in group 2 and a consumed leading boundary in group 1. */
|
||
function collectTermMatches(re: RegExp, clause: string): TermMatch[] {
|
||
const out: TermMatch[] = [];
|
||
re.lastIndex = 0;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = re.exec(clause)) !== null) {
|
||
const start = m.index + (m[1] || '').length;
|
||
out.push({ term: (m[2] || '').toLowerCase(), start, end: start + (m[2] || '').length });
|
||
if (m[0].length === 0) re.lastIndex++;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
interface ClauseSpan {
|
||
text: string;
|
||
start: number;
|
||
}
|
||
|
||
/** Split a line into clause segments, keeping each segment's start offset so
|
||
* line-level spans (masked URL tokens) can be mapped into their clause. */
|
||
function splitClauses(masked: string): ClauseSpan[] {
|
||
const out: ClauseSpan[] = [];
|
||
let start = 0;
|
||
for (let i = 0; i <= masked.length; i++) {
|
||
if (i === masked.length || CLAUSE_BOUNDARY_RE.test(masked[i])) {
|
||
out.push({ text: masked.slice(start, i), start });
|
||
start = i + 1;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Detect whether phase-scope prose describes integrating an external API/SDK.
|
||
*
|
||
* FAIL-CLOSED: it leans toward detecting, because a false positive is dismissed
|
||
* by a one-line COVERAGE.md declaration while a false negative silently slips a
|
||
* real external-API phase past a blocking gate. It fires when EITHER:
|
||
* (a) an integration VERB and an API NOUN share one CLAUSE ("integrate the
|
||
* Stripe API", "Connect … to api.stripe.com") — the clause boundary is the
|
||
* whole relationship test, so verb/noun in DIFFERENT clauses do not pair
|
||
* (#2365 acceptance #2). There is NO cross-clause binding: a service named
|
||
* only in a clause separate from its API noun is a documented limitation.
|
||
* (b) an explicit `<Service> API|SDK|REST|GraphQL` surface names a service
|
||
* that is not a stopword, a locality/protocol descriptor, a compound
|
||
* modifier, or first-party-qualified ("Stripe API", "Spotify SDK").
|
||
*
|
||
* Fenced code, inline code spans, and path-shaped tokens are excluded before
|
||
* matching. A package-shaped inline span (`@stripe/stripe-js`, `stripe-sdk`)
|
||
* and a URL that NAMES an API vocab word ("api.stripe.com/v1") still count as
|
||
* noun/dependency evidence; a bare host that names none does not.
|
||
*
|
||
* 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');
|
||
|
||
const hasCompoundTerms = effective.verbs.length > 0 && effective.nouns.length > 0;
|
||
// Trailing boundary is a LOOKAHEAD (not consumed) so back-to-back terms
|
||
// separated by one boundary char are both found.
|
||
const verbRe = hasCompoundTerms
|
||
? new RegExp(
|
||
'(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)',
|
||
'gi',
|
||
)
|
||
: null;
|
||
const nounRe = hasCompoundTerms
|
||
? new RegExp(
|
||
'(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)',
|
||
'gi',
|
||
)
|
||
: null;
|
||
const surfaceRe = new RegExp(SERVICE_SURFACE_API_RE.source, 'g');
|
||
|
||
const nounSet = new Set(effective.nouns);
|
||
|
||
const emitPair = (vTerm: string, nTerm: string, snippetLine: string): void => {
|
||
const key = `${vTerm}+${nTerm}`;
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
signals.push({ verb: vTerm, noun: nTerm, snippet: makeSnippet(snippetLine, nTerm) });
|
||
};
|
||
|
||
for (const rawLine of lines) {
|
||
// Inline code spans are code, not prose — mask them (length-preserving so
|
||
// offsets keep lining up), but keep package-shaped span content as noun
|
||
// evidence (#2365 review FN-4: `stripe-sdk` names a dependency).
|
||
const inlineSpans = scanInlineCodeSpans(rawLine);
|
||
let line = rawLine;
|
||
const spanNouns: TermMatch[] = [];
|
||
for (const s of inlineSpans) {
|
||
line = line.slice(0, s.start) + ' '.repeat(s.end - s.start) + line.slice(s.end);
|
||
const content = s.content.trim();
|
||
if (content.length === 0 || /\s/.test(content)) continue;
|
||
const segs = content.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||
if (segs.length < 2) continue; // a bare `api` span is a code identifier
|
||
const hit = segs.find((seg) => nounSet.has(seg));
|
||
if (hit) spanNouns.push({ term: hit, start: s.start, end: s.end });
|
||
}
|
||
|
||
// Path-shaped tokens (routes, file names, URLs) are references, not prose.
|
||
const { masked, urlNouns } = scanLineTokens(line, nounRe, nounSet);
|
||
const clauses = splitClauses(masked);
|
||
const extraNouns = urlNouns.concat(spanNouns);
|
||
|
||
// (a) compound verb+noun — SAME CLAUSE ONLY. There is no word-gap cap (a cap
|
||
// cannot tell a long genuine clause from a long internal one) and no
|
||
// cross-clause binding (see the note by CLAUSE_BOUNDARY_CHARS): the clause
|
||
// boundary is the whole relationship test. Nouns are NOT filtered on
|
||
// "internal" qualification here — "integrate the internal API" is a
|
||
// fail-closed positive; the declaration dismisses it if wrong.
|
||
//
|
||
// #2784: negation suppression. A clause that pairs an integration verb with
|
||
// an API noun but the verb itself is directly negated (e.g. "does not
|
||
// integrate", "integrates no external API") is suppressed. The check is
|
||
// scoped to the verb's immediate context (the word directly before the
|
||
// verb, or the word directly between verb and noun) — NOT a blanket
|
||
// clause-wide scan, because "without changing runtime dependencies" in a
|
||
// long clause does NOT negate the integration.
|
||
// KNOWN LIMIT (deliberate, not a bug to fix later): a negation further than
|
||
// 2 words before the verb, or a clause where the noun precedes the verb, is
|
||
// NOT suppressed — e.g. "Ships without any API integration." is NOT
|
||
// suppressed today (pinned by a test in tests/api-coverage.test.cjs).
|
||
// detectApiIntegration is fail-closed by design: an unsuppressed false
|
||
// positive costs a one-line COVERAGE.md declaration, while widening the
|
||
// window trades that for a silent false negative on a blocking gate.
|
||
if (verbRe && nounRe) {
|
||
for (const clause of clauses) {
|
||
const verbs = collectTermMatches(verbRe, clause.text);
|
||
if (verbs.length === 0) continue;
|
||
// #2784: check if any verb is immediately preceded by a negation
|
||
// qualifier (within 2 words before the verb match).
|
||
//
|
||
// OFFSET NOTE: `v.start`/`n.start` (from collectTermMatches below and
|
||
// above) are already CLAUSE-LOCAL — collectTermMatches was called with
|
||
// `clause.text`, not the full line — and so is `clauseText`
|
||
// (`clause.text.toLowerCase()`). They must be used AS-IS to index into
|
||
// `clauseText`; do not re-base them against `clause.start` (that field
|
||
// is the clause's offset within the LINE, a different coordinate space,
|
||
// used only to map line-level spans like `extraNouns`/`masked` into a
|
||
// clause). Subtracting `clause.start` here double-offsets the slice
|
||
// bounds for every clause after the first on a line (#3127 follow-up).
|
||
const clauseText = clause.text.toLowerCase();
|
||
const hasNegatedVerb = verbs.some((v) => {
|
||
const before = clauseText.slice(Math.max(0, v.start - 20), v.start);
|
||
const beforeWords = before.split(/\s+/).filter(Boolean).slice(-2);
|
||
return beforeWords.some((w: string) => NEGATION_QUALIFIERS.has(w.replace(/[^a-z']/g, '')));
|
||
});
|
||
// Also check if "no"/"zero"/"none" appears between the verb and the noun.
|
||
const nouns = collectTermMatches(nounRe, clause.text);
|
||
const nounTerms = new Set(nouns.map((t) => t.term));
|
||
for (const u of extraNouns) {
|
||
if (u.start >= clause.start && u.end <= clause.start + clause.text.length) {
|
||
nounTerms.add(u.term);
|
||
}
|
||
}
|
||
if (nounTerms.size === 0) continue;
|
||
// Check for negation between verb and noun.
|
||
//
|
||
// #3127 regression: the original form of this check was
|
||
// O(verbs × nouns), re-slicing and re-splitting the clause text for
|
||
// every (verb, noun) pair — effectively cubic in clause length (a
|
||
// clause of N repeated "integrate api" pairs did O(N^2) pair checks,
|
||
// each doing an O(N) slice/split). On a clause with 800 repeated
|
||
// pairs this took ~8.5s; fast-check's property test then generated
|
||
// documents large enough to hang the whole test file past node:test's
|
||
// 600s timeout. It ALSO subtracted `clause.start` from `v.start`/
|
||
// `n.start` before slicing `clauseText` — but `v.start`/`n.start` are
|
||
// already local to `clause.text` (collectTermMatches was called with
|
||
// clause.text, not the full line), and `clauseText` is exactly
|
||
// `clause.text.toLowerCase()`. So that subtraction double-offset the
|
||
// slice bounds for every clause after the first on a line, sliding
|
||
// (and for negative results, JS's negative-index slice() wraparound
|
||
// non-monotonically re-mapping) the window to characters unrelated to
|
||
// the verb/noun pair — an independent latent bug, fixed here as part
|
||
// of establishing a well-defined O(1) predicate (a piecewise/clamped
|
||
// window has no single "widest span" to reason about at all).
|
||
//
|
||
// EXACT-EQUIVALENCE, single pass: the predicate is "does any pair
|
||
// (v, n) with n.start > v.start have a negation token in the span
|
||
// (v.end, n.start)". Every such span is a SUBSET of the widest
|
||
// possible span for a given noun: [min(v.end) over verbs valid for
|
||
// that noun, n.start). And since that window only widens as a
|
||
// noun's start increases (more verbs become valid, and the noun
|
||
// bound itself grows), the single widest span across the WHOLE
|
||
// clause is anchored at the noun with the maximum start, using the
|
||
// minimum verb-end among verbs valid for THAT noun (not the global
|
||
// minimum verb-end, which could belong to a verb that starts after
|
||
// this noun and so is never a valid pairing with it — a mismatch
|
||
// that would either miss or falsely include a negation). If that one
|
||
// substring contains no negation token, no narrower pair-specific
|
||
// substring can either; if it does, the (minVerb, maxNoun) pair
|
||
// itself contains it. This drops the check to O(verbs + nouns).
|
||
let hasNegatedNoun = false;
|
||
if (nouns.length > 0) {
|
||
let minVerbStart = Infinity;
|
||
for (const v of verbs) if (v.start < minVerbStart) minVerbStart = v.start;
|
||
let maxNounStart = -Infinity;
|
||
for (const n of nouns) if (n.start > maxNounStart) maxNounStart = n.start;
|
||
if (maxNounStart > minVerbStart) {
|
||
let minQualifyingVerbEnd = Infinity;
|
||
for (const v of verbs) {
|
||
if (v.start < maxNounStart) {
|
||
const vEnd = v.start + v.term.length;
|
||
if (vEnd < minQualifyingVerbEnd) minQualifyingVerbEnd = vEnd;
|
||
}
|
||
}
|
||
const between = clauseText.slice(minQualifyingVerbEnd, maxNounStart);
|
||
const betweenWords = between.split(/\s+/).filter(Boolean);
|
||
hasNegatedNoun = betweenWords.some((w: string) =>
|
||
NEGATION_NOUN_TOKENS.has(w.replace(/[^a-z']/g, '')),
|
||
);
|
||
}
|
||
}
|
||
if (hasNegatedVerb || hasNegatedNoun) continue;
|
||
for (const vTerm of new Set(verbs.map((t) => t.term))) {
|
||
for (const nTerm of nounTerms) emitPair(vTerm, nTerm, rawLine);
|
||
}
|
||
}
|
||
}
|
||
|
||
// (b) explicit <Service> API|SDK|REST|GraphQL surface — scan every candidate
|
||
// in every clause (a rejected first candidate must not shadow a later
|
||
// genuine service; #2365 review C-1).
|
||
for (const clause of clauses) {
|
||
surfaceRe.lastIndex = 0;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = surfaceRe.exec(clause.text)) !== null) {
|
||
const svc = m[1] || '';
|
||
const svcLower = svc.toLowerCase();
|
||
// Reject capitalized sentence starters ("The API"), locality/protocol
|
||
// descriptors ("Internal API", "REST API"), compound modifiers
|
||
// ("Resolver-only API"), and services qualified first-party
|
||
// ("internal Payments API"). A real vendor name is none of these.
|
||
if (SERVICE_STOPWORDS.has(svcLower)) continue;
|
||
if (SURFACE_DESCRIPTOR_WORDS.has(svcLower)) continue;
|
||
if (COMPOUND_MODIFIER_RE.test(svc)) continue;
|
||
if (isInternallyQualified(masked, clause.start + m.index)) 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(rawLine, svc) });
|
||
}
|
||
}
|
||
}
|
||
|
||
return { detected: signals.length > 0, signals, terms: effective };
|
||
}
|
||
|
||
|
||
/** True when the word IMMEDIATELY ADJACENT before `offset` is a locality
|
||
* descriptor ("internal Payments API") — first-party qualification is negative
|
||
* evidence for an EXTERNAL-API signal. Only plain spaces/tabs may separate the
|
||
* descriptor from the service: any intervening punctuation means the descriptor
|
||
* belongs to a prior clause/sentence and must NOT qualify ("The cache is
|
||
* private. Stripe API …" — `private` is a different sentence; #2365 review).
|
||
* Looks back through a BOUNDED window, not the whole prefix, to stay linear. */
|
||
const QUALIFIER_LOOKBACK = 24; // longest descriptor ("first-party") + separators
|
||
function isInternallyQualified(masked: string, offset: number): boolean {
|
||
const from = offset > QUALIFIER_LOOKBACK ? offset - QUALIFIER_LOOKBACK : 0;
|
||
const window = masked.slice(from, offset);
|
||
// Only whitespace and markdown emphasis/wrapper markers (`*_~\`) may separate
|
||
// the descriptor from the service, so "The **internal** Payments API" still
|
||
// qualifies — but NOT a clause/sentence boundary, so "…is private. Stripe API"
|
||
// does not (the descriptor is a different sentence; #2365 review).
|
||
const m = /([A-Za-z0-9'-]+)[\s*_~`]*$/.exec(window);
|
||
if (!m) return false;
|
||
// A word truncated by the window start is not a descriptor match (its real
|
||
// start lies before the window) — fail toward detection.
|
||
if (from > 0 && m.index === 0 && /[A-Za-z0-9'-]/.test(masked[from - 1])) return false;
|
||
return INTERNAL_DESCRIPTORS.has(m[1].toLowerCase());
|
||
}
|
||
|
||
// ─── Coverage matrix parse / validate / render ────────────────────────────────
|
||
|
||
export type CoverageDecision = 'INTEGRATE' | 'OPT-OUT';
|
||
|
||
export interface CoverageRow {
|
||
capability: string;
|
||
decision: CoverageDecision;
|
||
reason: string;
|
||
}
|
||
|
||
/** #2365 acceptance #5: a first-class "this phase integrates no external API"
|
||
* declaration — the legitimate alternative to fabricating a matrix row for a
|
||
* capability that does not exist. Like an OPT-OUT row, it must carry a
|
||
* reason: the declaration is a reasoned decision, not a bypass. */
|
||
export interface CoverageNoneDeclaration {
|
||
none: true;
|
||
reason: string;
|
||
}
|
||
|
||
export interface CoverageParseResult {
|
||
rows: CoverageRow[];
|
||
errors: string[];
|
||
format: 'table' | 'json' | 'none';
|
||
declaration: CoverageNoneDeclaration | null;
|
||
}
|
||
|
||
export interface CoverageValidationResult {
|
||
valid: boolean;
|
||
errors: string[];
|
||
counts: { surface: number; integrate: number; optout: number };
|
||
/** True when a valid no-integration declaration (and no rows) satisfied the gate. */
|
||
none_declared?: boolean;
|
||
}
|
||
|
||
/** Matches a declaration line such as
|
||
* `No external API integration: <reason>` (also `**bold**` and em-dash
|
||
* separators). The reason is REQUIRED — a bare declaration does not parse.
|
||
* Deliberately NOT matched: blockquoted lines (`> No external …` is quoted
|
||
* text, not a declaration) and anything inside fenced code or HTML comments
|
||
* (both stripped before the scan; #2365 review C-3). */
|
||
const NO_INTEGRATION_DECLARATION_RE =
|
||
/^\s*(?:\*\*)?no external api integration(?:\*\*)?\s*(?:[:—–-]|--)\s*(\S[^\n]*)$/im;
|
||
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
||
|
||
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', declaration: null };
|
||
if (typeof text !== 'string') return out;
|
||
const src = text.replace(/\r\n/g, '\n');
|
||
|
||
// #2365 acceptance #5: a "no external API integration" declaration. Scanned
|
||
// on fence-stripped, comment-stripped text so an example inside a code block
|
||
// or an HTML comment does not count.
|
||
const declMatch = NO_INTEGRATION_DECLARATION_RE.exec(
|
||
stripFencedCode(src).text.replace(HTML_COMMENT_RE, ''),
|
||
);
|
||
if (declMatch) {
|
||
out.declaration = { none: true, reason: (declMatch[1] || '').trim() };
|
||
}
|
||
|
||
// (1) fenced ```coverage JSON block takes precedence if present.
|
||
// Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark).
|
||
const fenceBody = extractFencedBlock(src, 'coverage');
|
||
if (fenceBody) {
|
||
out.format = 'json';
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(fenceBody);
|
||
} 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 rows from coverage matrix tables only (#2366).
|
||
// Track whether we are inside a recognized coverage matrix (after a header
|
||
// row, before a non-pipe line ends the table). This prevents summary tables
|
||
// elsewhere in the file from being parsed as data (#2366 bug 1) and allows
|
||
// multi-section matrices with repeated headers (#2366 bug 2).
|
||
const lines = src.split('\n');
|
||
let inMatrix = false;
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith('|')) {
|
||
inMatrix = false;
|
||
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;
|
||
// Strip markdown emphasis (**, *, __, _, `) from the decision cell before
|
||
// comparison so **OPT-OUT** parses correctly (#2366 bug 3).
|
||
const decisionCell = (cleaned[1] || '').replace(/[*_`]/g, '').trim().toUpperCase();
|
||
// header detection — recognized by 'capability' in column 0; allows multiple
|
||
// headers for multi-section matrices (#2366 bug 2).
|
||
if (cleaned[0].toLowerCase() === 'capability') {
|
||
inMatrix = true;
|
||
if (out.format === 'none') out.format = 'table';
|
||
continue;
|
||
}
|
||
// Only parse data rows from inside a recognized coverage matrix table.
|
||
// A pipe-table outside the matrix (e.g., a summary table) is ignored (#2366 bug 1).
|
||
if (!inMatrix) 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;
|
||
|
||
// #2365 acceptance #5: a reasoned no-integration declaration with no rows
|
||
// satisfies the gate. A declaration ALONGSIDE rows is contradictory — the
|
||
// file must say one thing.
|
||
if (parsed.declaration) {
|
||
if (rows.length > 0) {
|
||
errors.push(
|
||
'declares "no external API integration" but also contains coverage rows — remove the declaration or the rows',
|
||
);
|
||
} else {
|
||
if (parsed.declaration.reason.length > REASON_MAX_LEN) {
|
||
errors.push(`declaration reason exceeds ${REASON_MAX_LEN} chars`);
|
||
}
|
||
const valid = errors.length === 0;
|
||
return {
|
||
valid,
|
||
errors,
|
||
counts: { surface: 0, integrate: 0, optout: 0 },
|
||
none_declared: valid,
|
||
};
|
||
}
|
||
}
|
||
|
||
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 codes (ADR-3889 Phase 3, #3907): 0 = integration detected, 1 = none
|
||
// (real input, examined, no signal), NO_INPUT (registry — src/cli-exit.cts) =
|
||
// stdin empty/whitespace-only, UNAVAILABLE (registry) = stdin read failed.
|
||
// The prior single "2 = startup error" arm let empty input flow into the
|
||
// detector and exit 1 — an unexamined input reported as an authoritative
|
||
// negative. Under --json, NO_INPUT/UNAVAILABLE emit `{skipped:true,
|
||
// reason:"no_input"|"stdin_error"}` — no `detected` key, so a stale
|
||
// `detected:false` can never sit beside `skipped:true`; the detected/no-signal
|
||
// `--json` payloads are byte-identical to before. Terminates via terminateNow
|
||
// (write-then-terminate, total by construction), not raw process.exit — these
|
||
// exits fire from inside a stdin event handler. Mirrors assumption-delta.cjs /
|
||
// ui-safety-gate.cjs.
|
||
|
||
if (require.main === module) {
|
||
const argv = process.argv.slice(2);
|
||
const wantJson = argv.includes('--json');
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const cliExit = require('./cli-exit.cjs') as { terminateNow: (outcome: string, payload: unknown) => never };
|
||
|
||
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('');
|
||
// Whitespace-only counts as empty (ADR-3889 §1's NO_INPUT: "zero units
|
||
// were in scope, and that emptiness is known to be genuine"). Real input
|
||
// — including a lone NUL byte, which .trim() does not strip — always
|
||
// falls through to the detector.
|
||
if (input.trim().length === 0) {
|
||
cliExit.terminateNow('NO_INPUT', wantJson ? { skipped: true, reason: 'no_input' } : undefined);
|
||
}
|
||
const result = detectApiIntegration(input, termsOverride);
|
||
cliExit.terminateNow(result.detected ? 'PASS' : 'FAIL', wantJson ? result : undefined);
|
||
});
|
||
process.stdin.on('error', (err: Error) => {
|
||
process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`);
|
||
cliExit.terminateNow('UNAVAILABLE', wantJson ? { skipped: true, reason: 'stdin_error' } : undefined);
|
||
});
|
||
}
|