* test(#3412): failing-first suite for the pattern-construction seam Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both suites fail with MODULE_NOT_FOUND, which is the intended RED. Locks the measured behavior rather than the assumed behavior: RegExp.escape hex-escapes the leading character of nearly every string ("abc" -> "\x61bc"), so the suite asserts match-equivalence against an inlined historical oracle (the implementation being deleted) rather than byte-equivalence of pattern text — 200 seeded fast-check runs plus a fixed corpus, 0 mismatches. Also locks the latent character-class range bug this phase fixes as a side effect: a hyphen-bearing value interpolated into [...] currently forms a real range and matches an unintended character; post-migration it must not. * chore(#3412): src/pattern.cts owns runtime-value regex construction Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam delegating to the built-in RegExp.escape, deletes every hand-rolled copy, and raises the Node floor to the Active LTS line. The census was low, three times over. ADR-3212 counted 10 copies; a graph query found 12; the new lint rule — once live — found 27 more. The difference is that the census counted named helper FUNCTIONS while the rule counts the escape SHAPE, so inline .replace(<class>, '\$&') copies were never in scope. ADR §1's actual requirement is that no module outside the seam escapes a value for regex use, so all of them are, and CLAUDE.md's no-defer rule makes them this change's work. Fourth consecutive epic here whose copy count was low — the argument for ADR-3180 Amendment 3's "state N found by the guard" rule. Also corrected mid-implementation: the survey reported phase-id.cts's escapeRegex had 0 external importers. It had 8 production importers, making its removal a public-surface change to an ADR-2121-owned module and requiring an update to that ADR's locked-surface test. Blast radius revised Medium-High -> High. RegExp.escape is match-equivalent but NOT text-equivalent: it hex-escapes the leading char of nearly every string ("abc" -> "\x61bc"). Equivalence is proven by a seeded fast-check property test against the deleted implementation as oracle. It also fixes a latent bug: a hyphen-bearing value interpolated into a character class previously formed a real range and matched an unintended character. Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines, .nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate `required-tests` context is unchanged and no job was added or removed, so branch protection cannot be orphaned by the dropped lanes. Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with structural provenance for reviewed pattern-fragment constants rather than a name heuristic) plus a whole-tree companion guard covering the directories ESLint's globs miss. * fix(#3412): close the _SOURCE guard evasion, correct two false claims Three findings from the orthogonal review pass, all fixed. 1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier- name matching with no binding check, so `new RegExp(userInput_SOURCE)` — a function parameter — sailed past the guard. That is the same rename-evasion class issue #3410 documents, reopened by the very fallback meant to complement the structural check. Now bound to the identifier's actual binding kind: import, require-derived const, or module-scope const; parameters, `let`/`var`, and unresolvable bindings fail closed. Four RuleTester cases cover the evasion and prove the legitimate cross-module case still passes. 2. src/pattern.cts's own header carried the stale pre-correction counts (12 copies / 17 call sites) while CONTEXT.md and the design doc carried the corrected ones (~39 / ~44) — a self-contradiction inside the PR whose entire purpose is deleting divergent copies. Rewritten, preserving the durable lesson: a named-function census cannot see inline copies; only a shape-matching guard can. 3. The claim that all deleted copies threw TypeError on non-string was false. phase-id.cts's copy — the one with 8 external importers — did String(value).replace(...) and never threw. The seam's locked signature does not coerce, so this is a real, now-disclosed behavior change rather than the pure preservation the tests asserted. Audited all 32 invocations across the 8 importers and 6 in-file callers: every one is safe by construction (upstream truthy guard or a string-producing derivation), verified by runtime probe against the compiled modules rather than by TS compilation, which cannot see a runtime undefined. Corrected the false claim in both the test comment and the design doc, and added it to Known limits. * docs(#3412): add Changed changeset for the Node 24 floor The only user-visible break in this phase. The escape-behavior change is internal and match-equivalent, so it carries no user-facing note. * fix(#3412): resolve the seam's require graph in script fixtures and packaging Checkpoint 2 came back red with 90 failures on the node24 lane. Three distinct defects, all introduced by routing scripts/ through the new pattern seam, none reproducible by any local gate: 1. ~82 failures — tests/adr-index-gate.test.cjs and tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an mkdtemp fixture and spawn it there (necessary: those scripts resolve their scan root from __dirname/.., so running the real script would scan the real repo). Each harness hand-listed the dependencies to copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to gen-adr-index.cjs made both lists silently incomplete -> MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON' failures from the same crash. Fixed as a class, not an instance: new tests/helpers/copy-script- fixture.cjs walks a script's transitive static relative-require graph and copies it, so dependencies are derived and never re-declared. It throws (naming the unbuilt artifact) instead of letting the child die with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host- contract, sync-runtime-launcher. 2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so the new scripts/lint-no-adhoc-regex-escape.cjs would be MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from the tarball, matching the existing precedent for gen-emitted- baseline.cjs, which is excluded for the identical reason, and locked with a test modeled on that one. Confirmed against a real npm pack: 890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs present (so the other four scripts' requires are legitimate). 3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to the retired hand-rolled escaper but NOT text-equivalent: it hex- escapes the leading character and all hyphens ('0*\x329', '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match decisions across all three real interpolation prefixes, zero divergence. Those tests now compile each source into the same heading regex src/roadmap.cts's searchPhaseInContent builds and assert what matches and what does not, including the 'i'-flag canonicalization the hex escape has to preserve. Re-pinning the new literals would have rebuilt the same brittleness one layer down. Adds a test for the property the escape exists for: a dot in '1.2' must not act as a wildcard. Also shares one definition of 'a require' between the packaging guard and the fixture copier, so the two cannot disagree about what they scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): refuse to copy a fixture dependency outside the fixture root copyScriptWithDeps resolved each relative require and joined the repo-relative result onto fixtureRoot. A require resolving OUTSIDE the repo yields a '../'-prefixed relative path, so path.join climbed out of the fixture and wrote into the surrounding temp dir (verified: repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd). No script in the tree does this today, so this closes an available escape rather than an active one. Refuses via the existing unresolved- require path so the failure names the offending specifier. Covered by a negative proof that the guard fires and that nothing lands outside the fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract Applies all findings from the second orthogonal review round, re-run because real code changed after round 1. HIGH (security) — extractRequires stripped BLOCK comments before LINE comments, so a '//' comment containing '/*' opened a phantom block comment, and a '//' inside a string literal truncated the line. Both hid real requires: 'const u="http://x"; require("./real.cjs")' returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were invisible. Replaced with a real AST parse via espree. This is ADR-3212's own Decision 4 — tokenizer-first for stateful grammars — applied to the case it describes; comment/string/regex nesting is exactly such a grammar, which is why the regex version was wrong. The function was moved byte-identical out of the #2858 packaging guard, so the bug PRE-DATES this branch and has been a live blind spot there: a shipped script could have required an unshipped path undetected. Fixing it makes that guard strictly stronger than on next. espree is promoted from a transitive eslint dependency to an explicit devDependency rather than relying on hoisting. The script parse attempt sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a function, making a top-level return legal — scripts/check-coverage-gate .cjs relies on it, and without the flag the guard throws on a file it is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a real npm pack, so the exact extractor does not newly fail the guard. MEDIUM (security) — the repo-containment check guarded dependencies but not the entry path. One escapesContainment predicate now guards both. LOW (security) — containment was lexical while fs follows symlinks, and a directory symlink could mint a fresh dedupe key per level. realpath now resolves both repoRoot and each dependency before the decision, and the realpath-derived path is the dedupe key. Destination layout still uses the original repo-relative path, so copied trees are unchanged. MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests lost the foreign-prefix contract: every assertion was satisfied by an impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599 bug class the exact-source prevents. The literal assertions it replaced were catching this. Now asserts the compiled regex REJECTS a different prefix with the same number. MAJOR (standards) — the test hand-duplicated production's heading regex with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed the parallel surface instead of policing it: src/roadmap.cts exports buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports it. Byte-identical .source and .flags verified for both escaped forms. MINOR — '..foo' no longer false-flagged as an escape; the inverted spurious-vs-missing doc claim corrected; the dead allow-test-rule header removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3412): backfill changeset pr number to 3416 * fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision Two CI failures on PR #3416, both in code this branch added. CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a bracket run be consumed EITHER by the character-class branch OR one character at a time by the trailing catch-all, so a failing match explored both parses of every pair. Measured on the real regex: n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script scans repo source, so a file with a long bracket run after '.replace(/' would hang CI outright — a guard against undisciplined pattern construction was itself the worst pattern in the diff. Fixed the way ADR-3212 already prescribes: the catch-all branch now excludes '[' and ']' so a bracket can only be consumed by the class branch (this is what makes it linear), and every quantifier is bounded (the locked bounded-quantifiers decision) as a second line of defense. Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the constant: a regex literal with a BARE unescaped ']' outside a class is no longer matched by this backstop. No census shape has that form, and the AST rule remains the primary detector. Verified the guard did not go blind doing it: a real census-shape violation is still reported, and an allow-adhoc-regex-escape suppression comment is still honored. Regression test drives the exported findViolations on a 2000-repetition adversarial input and asserts the RESULT. It makes no wall-clock assertion — elapsed-time tests are forbidden — so a regression surfaces as a harness timeout, which is the correct signal. Prompt injection scan — 'must not act as a regex wildcard' in a test comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if| my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a whole test file over one phrase would blunt the scanner permanently, and the comment has nothing to do with injection. Neither failure was reachable from the remote runner — CodeQL and the injection scan are not in that matrix, so the sha it passed was green and still wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
984 lines
46 KiB
TypeScript
984 lines
46 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:
|
||
* echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json]
|
||
* exit 0 = integration detected, 1 = none, 2 = startup 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 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);
|
||
});
|
||
}
|