* fix(#2365): stop the api-coverage detector false-positiving non-API phases detectApiIntegration fired on any integration verb co-occurring anywhere on a line with any API noun, treated / as a word boundary (so a first-party Next.js src/app/api/... route path matched the noun "api"), and read any capitalized word before API/SDK/REST/GraphQL as a service name behind a fixed stopword denylist (so threat-model prose like "Resolver-only API" fired). Because the verify:pre seal gate is BLOCKING, a phase touching no external API could not reach UAT without fabricating a coverage matrix. The compound rule now requires the verb and noun to share one clause (sentence punctuation and table-cell walls end a clause) within a bounded word gap. Non-prose spans are excluded before matching: fenced code (already), inline code spans (new stripInlineCode in the markdown-sectionizer seam), and path-shaped tokens. The <Service> API surface rule requires proper-noun position — a clause-initial capitalized word is ordinary English and needs dependency evidence (URL / package reference) on the same line — and rejects compound modifiers ("Resolver-only", lowercase after the hyphen). A phase that integrates no external API now has a first-class, reasoned way to say so: a COVERAGE.md containing "No external API integration: <reason>" satisfies the gate (declaration + rows is contradictory and blocks). The true-positive path is pinned by regression tests: every default-vocabulary positive still fires, including the widest word-gap pairing and the surface-rule-only shape. Fixes #2365 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2365): tighten api-coverage detector per Codex review (round 2) Applies the Codex review findings on the initial #2365 fix: - S-1: a COVERAGE.md "no external API integration" declaration is the human override for a fallible detector, so it must PASS even when detection still fires — but the contradiction is now SURFACED in the gate output (overridden signal count + terms) instead of passing silently. - S-2: verb/noun pairing is now a term-group nearest-pair merge walk over precomputed word ordinals (computeWordStarts / minWordGap), not a match×match cross product — a hostile line repeating one pair thousands of times stays linear instead of going quadratic. - FN-4: package-shaped inline-code spans (`stripe-sdk`, `@stripe/stripe-js`) are kept as noun/dependency evidence rather than being fully masked, so a genuine dependency reference inside code ticks still corroborates. - C-1: the <Service> API surface rule now scans every candidate in every clause; a rejected first candidate no longer shadows a later genuine service. - Cross-clause binding: a verb may bind a noun in the immediately following clause only when its own clause names a service object, within a tight gap — admits "Integrate Stripe, exposing its endpoints …" without re-admitting the unrelated-clauses false-positive class. - Internal-descriptor negative evidence ("internal Payments API", "the internal endpoint") never pairs; URL/scheme matching generalized beyond http(s). All 5 acceptance criteria still hold: the three reported false positives are clean and "integrate the Stripe API" still fires. Built .cjs committed alongside the .cts. tsc + eslint (incl. no-adhoc-markdown-parsing) + lint:regression-names clean; affected suites 256/256 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): retune api-coverage detector fail-closed per Codex review (round 3) Codex's second-round review found the round-2 tightening had over-corrected into FAIL-OPEN false negatives — realistic external-API prose that the BLOCKING seal gate silently let through (the catastrophic class, since a missed API surface is worse than a dismissable false positive). Retuned the detector to be explicitly fail-closed: lean toward detecting, and let the one-line COVERAGE.md "no external API integration" declaration dismiss the residual false positives. Fail-open false negatives fixed (all now detect): - F1 clause-initial `<Service> API` with a plain follower ("Stripe API for payment processing") — dropped the follower-allowlist / corroboration gate on clause-initial surfaces; a service that is not a stopword, descriptor, or compound modifier is a real name from any clause position. - F2 scheme-less external host ("api.stripe.com/v1") — a dotted host with an alphabetic final label now contributes its API nouns; a first-party route path (no dotted host) still does not. - F3 vendor's first-party SDK ("Integrate Shopify's first-party SDK") — the compound path no longer filters nouns on "internal"/"first-party" (Codex: the qualifier can describe the vendor's own API, not the consuming project's). - F4 long single integration clause — removed the word-gap cap entirely: it could not separate a 21-word genuine clause from an 18-word internal one, so the clause boundary is now the whole relationship test. - F5 lowercase cross-clause service — cross-clause binding no longer requires a capitalized "service object". New false positives fixed (all now clean): - F6 a URL token that swallowed a trailing clause comma, merging two clauses — trailing clause punctuation is kept literal so the split survives. - F7 a capitalized internal component authorizing cross-clause binding — the new gate requires a dependent elaboration, not a new coordinate clause opened by a conjunction ("…, then document…"). - F8 a protocol name read as a service ("REST API", "GraphQL API") — protocol and locality descriptors are rejected in the `<Service>` position. - Finding 9: the inline-code-span scanner was O(n^2) on pathological backtick runs; rewritten to linear via a per-length run cursor (2 MB: 4.15 s -> ~6 ms), semantics preserved (148 sectionizer tests unchanged). Net simplification: the fail-closed model removed the round-2 minWordGap / groupByTerm / follower / corroboration machinery (350 insertions vs 445 deletions across the touched files). Under fail-closed, three round-2 negative tests now correctly detect (integration verb + "internal"-qualified noun, and the distant-same-clause case); none were trek-e acceptance FPs. Verified: 1491/1491 unit tests pass; tsc + eslint (incl. no-adhoc-markdown- parsing) + lint:regression-names clean; all 8 review findings reproduced as regression tests, both directions. Built .cjs committed alongside the .cts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): resolve round-3 Codex review findings (fail-closed, round 4) Codex's round-3 adversarial review found the fail-closed retune had introduced new holes in both directions. Resolved: Fail-open false negatives (now detect): - External host addressing a PATH ("graph.microsoft.com/v1.0/me") is itself an integration surface and contributes an endpoint noun even when the host names no vocabulary word. A bare domain link with no path ("https://example.com") stays a non-signal, so "Integrate … from example.com, document …" is still clean. - Locality qualification ("internal", "private") no longer leaks across a sentence or clause boundary: only plain spaces may separate the descriptor from the service, so "The cache is private. Stripe API …" now detects. - Cross-clause binding: the fragile head-word cap (which could not tell a genuine "Connect … to Stripe payments, exposing its endpoints" from an unrelated "Integrate … from URL, document …" — both 4 words after the verb) is replaced by a participial-continuation rule: a verb binds a noun in the next clause only when that clause begins with an "-ing" elaboration. This fixes the 4-word-head false negative AND the false positive below at once. False positives (now clean): - Cross-clause no longer binds a finite continuation regardless of separator: "Wire the settings form. Document endpoint props." / "…; document …" / "…, document …" are separate actions, not elaborations. Perf (quadratic → linear): - The trailing-punctuation peel is a backward char scan instead of an unanchored `[…]+$` regex (16k chars: 156 ms → ~1 ms). - SERVICE_SURFACE_API_RE bounds the service-name length {1,40} so a hostile "A-A-…-x" run cannot drive O(n^2) backtracking (16k: 385 ms → ~3 ms). Consumer fail-open (blocking gate): - readPhaseScope now distinguishes "no plans" from a plan that EXISTS but is unreadable. On a read error the gate BLOCKS ("could not read the phase scope …") instead of silently certifying no-integration from partial scope — an unreadable plan could be the one describing the integration. Documented fail-closed tradeoffs, now pinned with tests so they are not "fixed" back into a fail-open: a clause-initial capitalized common word before "API" ("Payment API", "Search API") reads as a service name; a long clause pairs a verb with a distant noun; and a CommonMark inline code span that wraps a newline is matched within-line only. Codex judged these acceptable because the COVERAGE.md declaration is a cheap override. One documented limitation remains out of scope: "Integrate Stripe, and authenticate requests with its API" (a coordinate finite clause whose noun refers back by pronoun) needs coreference resolution, beyond a lexical detector. Verified: 379/379 affected + command-router tests pass (+14 new regression tests covering every round-3 finding, both directions); tsc + eslint (no-adhoc-markdown-parsing) + lint:regression-names clean. Built .cjs committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): simplify to robust core — remove whack-a-mole heuristics (round 5) Round-4 review confirmed the detector's two most complex features generate findings in both directions no matter how they are tuned, because they need a vendor dictionary + coreference the issue rules out in principle. Per the operator's "ship the robust core" decision, both are removed and their gaps are documented rather than chased further: - Cross-clause binding DELETED (allowsCrossClause / participle rule). It caused a fail-open on finite continuations ("Integrate Stripe; use its OAuth endpoints" — missed) and a false positive on "-ing"-SPELLED nouns ("…, billing endpoint terminology…" — wrongly fired). Detection is now same-clause only. - URL-path-as-evidence REVERTED. Treating every path-bearing URL as an endpoint fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x", a docs/repo link) and recreated routine UI-phase false positives. An external URL is evidence only when it NAMES an API vocabulary word ("api.stripe.com/v1"). Two fail-open cases are now DOCUMENTED limitations, pinned by tests so a future maintainer does not re-add the heuristics that caused the false positives above: a service named only in a clause separate from its API noun, and a bare external host that names no vocabulary word. Both are cheaply covered by the COVERAGE.md declaration and rare in real phase prose ("integrate the X API"). Also fixed from the round-4 review: - Qualification now survives markdown emphasis ("The **internal** Payments API" stays clean) while still not crossing a sentence/clause boundary. - readPhaseScope fail-closes on a REAL read failure (EACCES/EIO) enumerating the phase directory or reading the roadmap fallback — not only per-plan-file failures; a missing directory/section remains a legitimate no-op. The declaration-override path surfaces scope_read_error so an incomplete-scope override stays visible. - SERVICE_SURFACE_API_RE length-bound comment no longer overclaims. Net: the detector is same-clause verb+noun + `<Service> API` surface, with path/code/inline masking and a fail-closed posture. All five acceptance criteria hold. 1573/1573 unit tests pass; tsc + eslint (no-adhoc-markdown-parsing) + lint:regression-names clean. Built .cjs committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): close roadmap-fallback fail-open + stale JSDoc (round-5 review) The round-5 sanity review confirmed the detector simplification is sound (all acceptance positives fire, all required negatives clean) and flagged one real blocker plus a nit: - Blocker: readPhaseScope's roadmap fallback could still silently pass an UNREADABLE roadmap. getRoadmapPhaseWithFallback gated on fs.existsSync(), which returns false on EACCES/EIO too — so an unreadable ROADMAP.md read as "absent", no exception reached isRealReadFailure, and the blocking gate certified empty scope. Fixed at the source: read the roadmap directly and honor the function's OWN documented contract — null only on ENOENT (genuinely absent), otherwise throw. Both existing callers already wrap it in try/catch expecting that throw, and readPhaseScope now fail-closes (blocks) via its roadmap catch. Verified by a new e2e test (unreadable roadmap fallback → block). - Nit: the detectApiIntegration JSDoc still described the removed cross-clause participial binding and "every external hostname counts" — corrected to the actual same-clause-only behavior and the names-a-vocab-word URL rule. Verified: full unit suite green; tsc + eslint + lint:regression-names clean. Built .cjs committed (roadmap.cjs is gitignored/rebuilt, per repo convention). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#2365): backfill changeset PR number (#2397) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#2365): sync generated capability-registry + recapture install goldens CI surfaced two generated-artifact staleness issues (all failing test shards + lint-tests traced to these, not to a logic defect): - gsd-core/bin/lib/capability-registry.cjs was stale: the initial fix edited the ai-integration `api-coverage-plan-pre.md` fragment (added the "No external API integration" declaration section) but did not regenerate the registry, which embeds an inline copy of that fragment. Regenerated via `gen-capability-registry.cjs --write` — the diff is exactly the fragment text sync. Fixes `lint:generated-sync` and the "committed registry is in sync" + "registry integration" tests. - The 18 golden-install-parity fixtures were stale by exactly one hash line each — `gsd-core/references/api-coverage.md`, which this PR edits and which is a hashed installed artifact. Recaptured with `UPDATE_GOLDEN=1`; the diff is that single hash per runtime and nothing else. Fixes the `golden parity — *` tests. No source or behavior change — generated artifacts only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#2365): flip representative-corpus manifest to assert the fixed behavior The #2371 representative corpus (merged into next after this branch was cut) is a known-bug tripwire: it asserts each fixture's currentBuggyOutput so the test fails loudly the moment #2365 is fixed, at which point — per its own contract in representative-corpus.test.cjs — the fixer removes currentBuggyOutput so the assertion checks expectedDetected instead. This is that moment. Removed currentBuggyOutput from the three detector fixtures (nextjs-route-path, unrelated-verb-noun, threat-model-prose); the corpus now asserts detected:false, which the fail-closed same-clause detector satisfies. Notes updated to describe the fix rather than the bug. The #2366 matrix corpus is left untouched — that tripwire belongs to its own PR (#2374). Corpus test: 7/7 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#2365): skip chmod-000 fail-closed e2e tests on Windows The three fail-closed gate tests induce an unreadable plan / directory / roadmap with chmod 000, but Windows does not enforce POSIX mode bits — readFileSync still succeeds, so the gate never reaches the read-error path and the assertion fails on the windows-latest CI leg. The fail-closed LOGIC is platform- independent (readError → block) and is fully exercised on the macOS/Linux legs; only the method of inducing EACCES is POSIX-specific. Guard the three tests to skip on win32 as well as root, mirroring golden-install-parity's win32 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#2365): address trek-e review — glossary, clock-seam, IO injection, bounds Review response to PR #2397 (trek-e, CHANGES_REQUESTED). Fix logic unchanged; this closes the test/process-hygiene findings. Major: - CONTEXT.md "Markdown Sectionizer" glossary now lists the two exports this fix relies on, `stripInlineCode` and `scanInlineCodeSpans` (glossary is a PR gate). - Replaced the banned wall-clock assertion in the "hostile repeated-term line" test (Clock Seams rule — no elapsed-time asserts) with a deterministic signal-count assertion, which also directly verifies the term-dedup that keeps pairing linear (one signal for a 10k-pair line, not thousands). - Rewrote the three fail-closed read-failure tests: instead of chmod 0o000 (a no-op under root / on Windows, the pattern the repo's IO-failure convention avoids) they now exercise the newly-exported `readPhaseScope` in-process and inject the failure by monkeypatching fs.readFileSync/readdirSync to throw, restoring in finally. Deterministic and platform-independent (no skip needed), and they add the ENOENT-is-absence case that the chmod tests couldn't express. Minor: - Added limit / limit+1 boundary tests for SERVICE_SURFACE_API_RE's {1,40} service-name bound, QUALIFIER_LOOKBACK's 24-char window, and REASON_MAX_LEN (200) on the declaration reason. - Added a fast-check property that fuzzes the tokenizer / clause splitter / masking (scanLineTokens, splitClauses, collectTermMatches) with adversarial tokens (slashes, backticks, URLs, clause punctuation) and asserts the detector is total (never throws), shape-stable, holds detected <=> signals, and is deterministic. readPhaseScope is exported for the in-process tests. Verified: 125 detector + 19 gate tests pass; tsc + eslint + generated-sync (glossary/registry) + lint-regression-test-names + lint-test-file-count clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
387 lines
16 KiB
JavaScript
387 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* E2E capability-wiring tests for the API-coverage gate (#1562).
|
|
*
|
|
* Drives the real CLI subprocess (`loop render-hooks verify:pre` and
|
|
* `check api-coverage.verify-pre`) against temp projects to prove:
|
|
* - the gate is data-driven (activates/deactivates by config) — acceptance #5
|
|
* - the seal contract (block / pass) — acceptance #1, #2, #4
|
|
* - the matrix persists on disk and is read at seal time — acceptance #6
|
|
*
|
|
* CONTENT/E2E only: every test drives a real CLI subprocess. No readFileSync
|
|
* source-grep. Genuine assertions: each case asserts the SPECIFIC differing
|
|
* value (block true/false, capId presence), not a count.
|
|
*/
|
|
|
|
const { describe, test, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
// In-process seam for the fail-closed read-injection tests at the bottom of this
|
|
// file (#2365 review): readPhaseScope is the pure phase-scope reader behind the
|
|
// gate. Those tests monkeypatch fs rather than drive a subprocess.
|
|
const { readPhaseScope } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
|
|
|
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
const TEST_ENV_BASE = {
|
|
GSD_SESSION_KEY: '',
|
|
CODEX_THREAD_ID: '',
|
|
CLAUDE_SESSION_ID: '',
|
|
CLAUDE_CODE_SSE_PORT: '',
|
|
OPENCODE_SESSION_ID: '',
|
|
GEMINI_SESSION_ID: '',
|
|
CURSOR_SESSION_ID: '',
|
|
WINDSURF_SESSION_ID: '',
|
|
TERM_SESSION: '',
|
|
WT_SESSION: '',
|
|
TMUX_PANE: '',
|
|
ZELLIJ_SESSION_NAME: '',
|
|
TTY: '',
|
|
SSH_TTY: '',
|
|
};
|
|
|
|
function runTools(args, cwd) {
|
|
const argv = Array.isArray(args)
|
|
? args
|
|
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
|
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, ...TEST_ENV_BASE },
|
|
timeout: 60000,
|
|
});
|
|
return { success: true, output: stdout.trim(), exitCode: 0, error: '' };
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
output: err.stdout?.toString().trim() || '',
|
|
error: err.stderr?.toString().trim() || err.message,
|
|
exitCode: err.status ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
function makeProject(workflow) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-apicov-'));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow }),
|
|
'utf8'
|
|
);
|
|
return tmpDir;
|
|
}
|
|
|
|
function makePhaseDir(projectDir, phaseSlug) {
|
|
const dir = path.join(projectDir, '.planning', 'phases', phaseSlug);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function writePlan(phaseDir, planFile, body) {
|
|
fs.writeFileSync(path.join(phaseDir, planFile), body, 'utf8');
|
|
}
|
|
|
|
function writeCoverage(phaseDir, body) {
|
|
fs.writeFileSync(path.join(phaseDir, 'COVERAGE.md'), body, 'utf8');
|
|
}
|
|
|
|
function verifyPreHooks(cwd) {
|
|
const result = runTools('loop render-hooks verify:pre --raw', cwd);
|
|
assert.ok(result.success, `render-hooks verify:pre should succeed. stderr: ${result.error}`);
|
|
const envelope = JSON.parse(result.output);
|
|
assert.strictEqual(envelope.point, 'verify:pre', 'point field must be verify:pre');
|
|
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
|
return envelope;
|
|
}
|
|
|
|
function findCap(envelope, capId) {
|
|
return envelope.activeHooks.find((h) => h.capId === capId) || null;
|
|
}
|
|
|
|
function runGate(cwd, phaseDir) {
|
|
return runTools(['check', 'api-coverage.verify-pre', phaseDir, '--raw'], cwd);
|
|
}
|
|
|
|
// ─── Capability wiring: data-driven activation (acceptance #5) ───────────────
|
|
|
|
describe('api-coverage verify:pre gate — capability wiring (#1562 acceptance #5)', () => {
|
|
let tmpDir;
|
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
|
|
|
test('gate is ACTIVE when workflow.api_coverage_gate is true', () => {
|
|
tmpDir = makeProject({ api_coverage_gate: true });
|
|
const env = verifyPreHooks(tmpDir);
|
|
const hook = findCap(env, 'ai-integration');
|
|
assert.ok(hook, 'ai-integration gate must register at verify:pre when enabled');
|
|
assert.strictEqual(hook.kind, 'gate');
|
|
assert.strictEqual(hook.blocking, true);
|
|
assert.strictEqual(hook.check.query, 'api-coverage.verify-pre');
|
|
});
|
|
|
|
test('gate is ABSENT when workflow.api_coverage_gate is false', () => {
|
|
tmpDir = makeProject({ api_coverage_gate: false });
|
|
const env = verifyPreHooks(tmpDir);
|
|
assert.strictEqual(findCap(env, 'ai-integration'), null, 'gate must not register when disabled');
|
|
});
|
|
|
|
test('gate is ACTIVE by default when the key is absent (opt-out, not opt-in)', () => {
|
|
tmpDir = makeProject({});
|
|
const env = verifyPreHooks(tmpDir);
|
|
assert.ok(findCap(env, 'ai-integration'), 'gate must default ON (full-coverage-by-default)');
|
|
});
|
|
});
|
|
|
|
// ─── Seal contract: block / pass (acceptance #1, #2, #4, #6) ──────────────────
|
|
|
|
describe('api-coverage.verify-pre — seal contract (#1562 acceptance #1,#2,#4,#6)', () => {
|
|
let tmpDir;
|
|
let phaseDir;
|
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
|
|
|
function fresh() {
|
|
tmpDir = makeProject({ api_coverage_gate: true });
|
|
phaseDir = makePhaseDir(tmpDir, '01-pay');
|
|
return phaseDir;
|
|
}
|
|
|
|
test('#1 API phase without a matrix → BLOCKS the seal', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payment processing.');
|
|
const r = runGate(tmpDir, phaseDir);
|
|
assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, true, 'must block when API integration has no matrix');
|
|
assert.strictEqual(j.detected, true);
|
|
assert.strictEqual(j.coverage_present, false);
|
|
});
|
|
|
|
test('#4 non-API phase without a matrix → does NOT block', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nRefactor the auth helper to use bcrypt.');
|
|
const r = runGate(tmpDir, phaseDir);
|
|
assert.ok(r.success, `gate should succeed. stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false, 'must not block a non-API phase');
|
|
assert.strictEqual(j.detected, false);
|
|
});
|
|
|
|
test('#1/#6 API phase WITH a valid matrix → passes (matrix persists on disk)', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payment processing.');
|
|
writeCoverage(
|
|
phaseDir,
|
|
'| capability | decision | reason |\n|---|---|---|\n' +
|
|
'| charge | INTEGRATE | |\n| refund | OPT-OUT | not needed yet |\n'
|
|
);
|
|
const r = runGate(tmpDir, phaseDir);
|
|
assert.ok(r.success, `gate should succeed. stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false);
|
|
assert.strictEqual(j.coverage_present, true);
|
|
assert.strictEqual(j.counts.surface, 2);
|
|
assert.strictEqual(j.counts.optout, 1);
|
|
});
|
|
|
|
test('#2 OPT-OUT without a reason → BLOCKS (un-decided hole)', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.');
|
|
writeCoverage(
|
|
phaseDir,
|
|
'| capability | decision | reason |\n|---|---|---|\n| refund | OPT-OUT | |\n'
|
|
);
|
|
const r = runGate(tmpDir, phaseDir);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, true, 'opt-out without reason must block');
|
|
assert.ok(j.errors.some((e) => /missing reason/i.test(e)));
|
|
});
|
|
|
|
test('#2 empty matrix → BLOCKS (surface must be enumerated)', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.');
|
|
writeCoverage(phaseDir, '| capability | decision | reason |\n|---|---|---|\n');
|
|
const r = runGate(tmpDir, phaseDir);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, true);
|
|
assert.ok(j.errors.some((e) => /empty/i.test(e)));
|
|
});
|
|
|
|
test('#3 a second platform with full-coverage baseline is accepted (no asymmetry)', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nAdd a YouTube SDK as a second media platform.');
|
|
// Full-coverage baseline for the second platform: every capability decided.
|
|
writeCoverage(
|
|
phaseDir,
|
|
'| capability | decision | reason |\n|---|---|---|\n' +
|
|
'| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | INTEGRATE | |\n'
|
|
);
|
|
const r = runGate(tmpDir, phaseDir);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false, 'a fully-decided second platform seals clean');
|
|
assert.strictEqual(j.counts.surface, 3);
|
|
});
|
|
|
|
test('JSON-fenced matrix is accepted (machine-generated form)', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.');
|
|
writeCoverage(
|
|
phaseDir,
|
|
'```coverage\n[{"capability":"charge","decision":"INTEGRATE","reason":""}]\n```\n'
|
|
);
|
|
const r = runGate(tmpDir, phaseDir);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false);
|
|
assert.strictEqual(j.counts.surface, 1);
|
|
});
|
|
|
|
// ── #2365: detector false positives must not block, and a phase may declare
|
|
// "no external API integration" instead of fabricating a matrix row.
|
|
test('#2365 phase naming a first-party route path → does NOT block', () => {
|
|
fresh();
|
|
writePlan(
|
|
phaseDir,
|
|
'01-PLAN.md',
|
|
'# Plan\nRun integration tests for src/app/api/profile/route.test.ts.'
|
|
);
|
|
const r = runGate(tmpDir, phaseDir);
|
|
assert.ok(r.success, `gate should succeed. stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false, 'a first-party route path is not an external API');
|
|
assert.strictEqual(j.detected, false);
|
|
});
|
|
|
|
test('#2365 COVERAGE.md declaring no external API integration → passes the gate', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nRender the export page.');
|
|
writeCoverage(phaseDir, 'No external API integration: UI-only phase, no third-party surface.\n');
|
|
const r = runGate(tmpDir, phaseDir);
|
|
assert.ok(r.success, `gate should succeed. stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false, 'a reasoned no-integration declaration satisfies the gate');
|
|
assert.strictEqual(j.coverage_present, true);
|
|
assert.strictEqual(j.none_declared, true);
|
|
assert.strictEqual(j.detected, false, 'a non-API plan shows no overridden signals');
|
|
});
|
|
|
|
test('#2365 declaration overriding live detection passes but SURFACES the contradiction', () => {
|
|
fresh();
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payments.');
|
|
writeCoverage(phaseDir, 'No external API integration: detector over-fired; this phase is UI-only.\n');
|
|
const r = runGate(tmpDir, phaseDir);
|
|
assert.ok(r.success, `gate should succeed. stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false, 'the declaration is the human overrule — it must win');
|
|
assert.strictEqual(j.none_declared, true);
|
|
assert.strictEqual(j.detected, true, 'the contradiction must be visible, not silent');
|
|
assert.ok(Array.isArray(j.signals) && j.signals.length > 0);
|
|
assert.ok(/overrid/i.test(j.message), `message should surface the override: ${j.message}`);
|
|
});
|
|
|
|
// ── Security (#1562 security review S1/S2): the phase arg is taken only as a
|
|
// token resolved under .planning/phases/. Traversal / unresolvable args must
|
|
// NOT read files outside the phase dir, and — since the phases tree exists —
|
|
// must fail CLOSED (a blocking gate must not silently bypass on a bad arg).
|
|
test('path-traversal arg is contained and fails CLOSED (phases tree exists)', () => {
|
|
fresh(); // creates .planning/phases/01-pay
|
|
const r = runTools(['check', 'api-coverage.verify-pre', '../../etc', '--raw'], tmpDir);
|
|
assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, true, 'unresolvable phase under an existing phases tree must block');
|
|
assert.strictEqual(j.phase_lookup_failed, true);
|
|
});
|
|
|
|
test('no .planning/phases at all → fail-open (genuine non-GSD project)', () => {
|
|
// A project with .planning/config.json but no phases directory.
|
|
const noPhases = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-apicov-nophase-'));
|
|
try {
|
|
fs.mkdirSync(path.join(noPhases, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(noPhases, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { api_coverage_gate: true } }),
|
|
'utf8'
|
|
);
|
|
const r = runTools(['check', 'api-coverage.verify-pre', '01-pay', '--raw'], noPhases);
|
|
assert.ok(r.success);
|
|
const j = JSON.parse(r.output);
|
|
assert.strictEqual(j.block, false, 'no phases tree → pass (not a GSD project)');
|
|
} finally {
|
|
cleanup(noPhases);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Fail-closed phase-scope read failures (in-process, #2365 review) ──────────
|
|
// These exercise readPhaseScope directly and inject the read failure by
|
|
// monkeypatching fs (restored in finally) rather than chmod 0o000 — chmod does
|
|
// not fault under root and is the pattern this repo's IO-failure convention
|
|
// avoids. Deterministic and platform-independent, so no root/win32 skip needed.
|
|
describe('readPhaseScope — fail-closed on a real read failure (#2365 review)', () => {
|
|
let tmpDir;
|
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
|
|
|
// Run `fn` with `fs[method]` throwing `code` for any path matching `pat`,
|
|
// delegating to the real implementation otherwise; always restored.
|
|
function withFsThrow(method, pat, code, fn) {
|
|
const orig = fs[method];
|
|
fs[method] = (p, ...rest) => {
|
|
if (typeof p === 'string' && pat.test(p)) {
|
|
const err = new Error(`${code}: injected read failure`);
|
|
err.code = code;
|
|
throw err;
|
|
}
|
|
return orig(p, ...rest);
|
|
};
|
|
try { return fn(); } finally { fs[method] = orig; }
|
|
}
|
|
|
|
test('an EXISTING plan file that cannot be read → readError set (not silent-empty)', () => {
|
|
tmpDir = makeProject({ api_coverage_gate: true });
|
|
const phaseDir = makePhaseDir(tmpDir, '01-pay');
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nRefactor the UI.');
|
|
writePlan(phaseDir, '02-PLAN.md', '# Plan\nIntegrate the Stripe API.');
|
|
const res = withFsThrow('readFileSync', /02-PLAN\.md$/, 'EACCES', () =>
|
|
readPhaseScope(tmpDir, phaseDir, '01'));
|
|
assert.ok(res.readError, 'a real plan read failure must set readError, not read as empty scope');
|
|
assert.match(res.readError, /could not read/i);
|
|
});
|
|
|
|
test('a phase directory that cannot be enumerated → readError set', () => {
|
|
tmpDir = makeProject({ api_coverage_gate: true });
|
|
const phaseDir = makePhaseDir(tmpDir, '01-pay');
|
|
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.');
|
|
const res = withFsThrow('readdirSync', new RegExp(phaseDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), 'EACCES', () =>
|
|
readPhaseScope(tmpDir, phaseDir, '01'));
|
|
assert.ok(res.readError, 'an unreadable phase directory must set readError, not read as empty');
|
|
});
|
|
|
|
test('roadmap fallback that cannot be read → readError set', () => {
|
|
tmpDir = makeProject({ api_coverage_gate: true });
|
|
const phaseDir = makePhaseDir(tmpDir, '01-pay'); // no plans → roadmap fallback
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 01: Pay\n\nIntegrate the Stripe API.\n',
|
|
'utf8'
|
|
);
|
|
const res = withFsThrow('readFileSync', /ROADMAP\.md$/, 'EACCES', () =>
|
|
readPhaseScope(tmpDir, phaseDir, '01'));
|
|
assert.ok(res.readError, 'an unreadable roadmap fallback must set readError, not read as absent');
|
|
});
|
|
|
|
test('a MISSING phase dir / roadmap is legitimate absence (ENOENT) → readError null', () => {
|
|
tmpDir = makeProject({ api_coverage_gate: true });
|
|
const missing = path.join(tmpDir, '.planning', 'phases', '99-does-not-exist');
|
|
const res = readPhaseScope(tmpDir, missing, '99');
|
|
assert.strictEqual(res.readError, null, 'ENOENT is absence, not a read failure — must not block');
|
|
assert.strictEqual(res.text, '');
|
|
});
|
|
});
|