* 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>
377 lines
18 KiB
JavaScript
377 lines
18 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const { HOST_LOOP_FILES, scanWiredPoints } = require('../scripts/gen-loop-host-contract.cjs');
|
|
const { lfByteCount } = require('../scripts/workflow-size.cjs');
|
|
|
|
const CORE_SUBSTRATE_TERMS = [
|
|
'Verification substrate',
|
|
'verifier↔predicate contract',
|
|
'Probe Core Module',
|
|
'Edge Probe Module',
|
|
];
|
|
|
|
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const { isCentralConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
|
|
|
|
function readRepoFile(relativePath) {
|
|
return fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
|
|
}
|
|
|
|
function activeWhenKeys() {
|
|
const keys = new Set();
|
|
for (const cap of Object.values(registry.capabilities)) {
|
|
for (const group of ['steps', 'gates', 'contributions']) {
|
|
for (const hook of cap[group] || []) {
|
|
if (hook.when) keys.add(hook.when);
|
|
}
|
|
}
|
|
}
|
|
return [...keys].sort();
|
|
}
|
|
|
|
describe('ADR-857 Phase 6 capstone conformance (#1139)', () => {
|
|
test('first-party optional feature capabilities are declared in the generated registry', () => {
|
|
const expectedFeatureCapabilities = [
|
|
'ai-integration',
|
|
'audit',
|
|
'code-review',
|
|
'graphify',
|
|
'intel',
|
|
'nyquist',
|
|
'pattern-mapper',
|
|
'research',
|
|
'security',
|
|
'ui',
|
|
];
|
|
|
|
for (const capId of expectedFeatureCapabilities) {
|
|
assert.equal(registry.capabilities[capId]?.role, 'feature', `${capId} must be a feature Capability`);
|
|
}
|
|
});
|
|
|
|
test('core verification substrate is documented as deliberately not capability-owned', () => {
|
|
const context = readRepoFile('CONTEXT.md');
|
|
for (const term of CORE_SUBSTRATE_TERMS) {
|
|
assert.match(context, new RegExp(escapeRegExp(term)), `${term} must be documented in CONTEXT.md`);
|
|
}
|
|
});
|
|
|
|
test('host loop files do not read capability hook activation keys directly', () => {
|
|
const forbiddenKeys = activeWhenKeys();
|
|
assert.ok(forbiddenKeys.length > 0, 'registry must expose hook activation keys');
|
|
|
|
for (const relativePath of HOST_LOOP_FILES) {
|
|
const content = readRepoFile(relativePath);
|
|
for (const key of forbiddenKeys) {
|
|
assert.doesNotMatch(
|
|
content,
|
|
new RegExp(`\\bconfig-get\\s+${escapeRegExp(key)}\\b`),
|
|
`${relativePath} must resolve ${key} through Capability hooks/state, not direct config-get`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('capability-owned config keys are not reintroduced into the central schema', () => {
|
|
for (const key of Object.keys(registry.configKeys).sort()) {
|
|
assert.equal(
|
|
isCentralConfigKey(key),
|
|
false,
|
|
`${key} is owned by capability ${registry.configKeys[key]} and must stay out of central config schema`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('host loop workflow files have a measurable, non-empty byte size', () => {
|
|
// Was asserted against the committed tests/workflow-size-baseline.json snapshot;
|
|
// #2724 (ADR-2719 Phase 4) deletes that file — the differential attribution
|
|
// check's size ratchet (tests/emitted-attribution.test.cjs) is the replacement
|
|
// anti-creep mechanism, but this test's actual intent was narrower: prove these
|
|
// host-loop files are real, tracked, non-empty workflow docs. Asserting the
|
|
// live byte count via the same shared counter the size guards use preserves
|
|
// that intent without depending on a committed snapshot.
|
|
for (const relativePath of HOST_LOOP_FILES) {
|
|
const fileName = path.basename(relativePath);
|
|
const bytes = lfByteCount(path.join(ROOT, relativePath));
|
|
assert.ok(bytes > 0, `${fileName} must be a non-empty workflow file`);
|
|
}
|
|
});
|
|
|
|
// ─── Phase-6 conformance: RED BY DESIGN until phase 6 is actually complete ──────
|
|
//
|
|
// #1139 closed (via #1158) with a green "capstone conformance gate" while the
|
|
// ADR-857 phase-6 acceptance criteria were unmet — a false green. The three
|
|
// tests below assert the real criteria with NO paper-over allowlist, so the
|
|
// gate stays RED until the work lands. Green here must mean "phase 6 conformant,"
|
|
// not "no new regression." Fixes tracked in #1167 / #1168 / #1169.
|
|
|
|
test('every declared capability hook point has a render-hooks call site in the host loop (#1168)', () => {
|
|
// No allowlist: every point a capability declares a hook at MUST have a
|
|
// `render-hooks` call site in the host loop, or those hooks can never fire.
|
|
const declaredPoints = new Set();
|
|
for (const cap of Object.values(registry.capabilities)) {
|
|
for (const group of ['steps', 'gates', 'contributions']) {
|
|
for (const hook of cap[group] || []) {
|
|
if (hook.point) declaredPoints.add(hook.point);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan only the host loop files (a `render-hooks` mention in a non-host
|
|
// workflow must not mask a lost host call site).
|
|
const callSites = new Set();
|
|
for (const relativePath of HOST_LOOP_FILES) {
|
|
const content = readRepoFile(relativePath);
|
|
for (const pt of scanWiredPoints(content)) callSites.add(pt);
|
|
}
|
|
|
|
const orphaned = [...declaredPoints].sort().filter((p) => !callSites.has(p));
|
|
assert.deepEqual(
|
|
orphaned, [],
|
|
`ADR-857 phase 6 is NOT complete: capability hooks declare these extension points ` +
|
|
`but no host-loop workflow calls \`gsd_run loop render-hooks <point>\`, so the hooks ` +
|
|
`can never fire: ${orphaned.join(', ')}. Wire each call site (#1167/#1169).`,
|
|
);
|
|
});
|
|
|
|
test('all ADR-857-named optional features are real Capabilities, not empty stubs (#1169)', () => {
|
|
// ADR-857 §53 + Decision 7 enumerate these optional, non-loop modules as
|
|
// Capabilities. "Migrated" means the feature OWNS its behavior: hook-based
|
|
// features (tdd/schema-gate/drift/gap-analysis) must declare >=1 hook;
|
|
// command-family features (profile-pipeline) must declare a command family.
|
|
// A registration-only stub (role:feature but no hooks/commands) games this
|
|
// gate while the logic stays welded into the loop — rejected here.
|
|
const REQUIRED = ['tdd', 'schema-gate', 'drift', 'gap-analysis', 'profile-pipeline'];
|
|
const problems = [];
|
|
for (const id of REQUIRED) {
|
|
const cap = registry.capabilities[id];
|
|
if (!cap) { problems.push(`${id}: not registered`); continue; }
|
|
if (cap.role !== 'feature') { problems.push(`${id}: role="${cap.role}", must be "feature"`); continue; }
|
|
const hookCount = (cap.steps?.length || 0) + (cap.contributions?.length || 0) + (cap.gates?.length || 0);
|
|
const isCommandFamily = (cap.commands?.length || 0) > 0;
|
|
if (hookCount === 0 && !isCommandFamily) {
|
|
problems.push(`${id}: EMPTY STUB (no hooks, no command family) — inline logic was not migrated; declare the real hooks/commands and remove the inline branch`);
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
problems, [],
|
|
`ADR-857 phase 6 is NOT complete:\n ${problems.join('\n ')}\n` +
|
|
`Each feature must OWN its behavior via hooks or a command family — not exist as a registration-only stub (#1169).`,
|
|
);
|
|
});
|
|
|
|
test('host loop reads no capability-owned config key inline (#1169)', () => {
|
|
// Phase 6 requires the loop to resolve capability behavior via render-hooks,
|
|
// not by reading capability-owned keys directly. Any inline `config-get` of a
|
|
// registry-owned key is an incomplete migration (the loop still owns the
|
|
// feature's params).
|
|
const leaks = [];
|
|
for (const relativePath of HOST_LOOP_FILES) {
|
|
const content = readRepoFile(relativePath);
|
|
for (const key of Object.keys(registry.configKeys)) {
|
|
if (new RegExp(`\\bconfig-get\\s+${escapeRegExp(key)}\\b`).test(content)) {
|
|
leaks.push(`${path.basename(relativePath)} → ${key} (owned by ${registry.configKeys[key]})`);
|
|
}
|
|
}
|
|
}
|
|
leaks.sort();
|
|
assert.deepEqual(
|
|
leaks, [],
|
|
`ADR-857 phase 6 is NOT complete: the host loop reads capability-owned config keys ` +
|
|
`inline:\n ${leaks.join('\n ')}\nThe owning capability must render/consume these (#1169).`,
|
|
);
|
|
});
|
|
|
|
test('host loop bodies are materially smaller than the pre-phase-6 baseline (#1168)', () => {
|
|
// #1139 AC: plan-phase.md / execute-phase.md must shrink as optional features
|
|
// extract to capabilities. Frozen pre-phase-6 sizes (LF bytes); the files must
|
|
// drop strictly below these. This also defeats double-run gaming — declaring a
|
|
// hook while leaving the inline block keeps the file from shrinking -> red.
|
|
//
|
|
// #1298: the execute-phase.md ceiling was raised from 93166 to accommodate
|
|
// wiring the mandatory `worktree record-agent` writer verb into the per-agent
|
|
// wave-manifest append. That verb is privileged host machinery (ADR-857
|
|
// Decision #1) — NOT the optional-feature inline logic this budget ratchets
|
|
// toward capabilities — so its footprint legitimately raises the host-loop
|
|
// ceiling rather than signalling an un-extracted optional feature.
|
|
const { lfByteCount } = require('../scripts/workflow-size.cjs');
|
|
const PRE_PHASE6 = { 'plan-phase.md': 94519, 'execute-phase.md': 93600 };
|
|
const notShrunk = [];
|
|
for (const [file, frozen] of Object.entries(PRE_PHASE6)) {
|
|
const now = lfByteCount(path.join(ROOT, 'gsd-core', 'workflows', file));
|
|
if (now >= frozen) notShrunk.push(`${file}: ${now} bytes (must be < pre-phase-6 ${frozen})`);
|
|
}
|
|
assert.deepEqual(
|
|
notShrunk, [],
|
|
`ADR-857 phase 6 is NOT complete: host loop bodies have not shrunk — the optional ` +
|
|
`feature logic has not actually been extracted:\n ${notShrunk.join('\n ')}`,
|
|
);
|
|
});
|
|
|
|
describe('ADR-857 phase 6 — capabilities must not bake install paths into the registry', () => {
|
|
// Matches GSD install paths that LEAK when copied verbatim to non-Claude runtimes.
|
|
// (~/.claude/projects is a legit runtime feature and is intentionally NOT matched.)
|
|
const LEAK = /\.claude[/\\](?:gsd-core|commands|agents|hooks)\b/;
|
|
|
|
test('no capability source (capability.json or fragment) embeds a ~/.claude install path', () => {
|
|
const capsDir = path.join(__dirname, '..', 'capabilities');
|
|
const offenders = [];
|
|
for (const id of fs.readdirSync(capsDir)) {
|
|
const dir = path.join(capsDir, id);
|
|
if (!fs.statSync(dir).isDirectory()) continue;
|
|
const cj = path.join(dir, 'capability.json');
|
|
if (fs.existsSync(cj) && LEAK.test(fs.readFileSync(cj, 'utf8'))) {
|
|
offenders.push(`capabilities/${id}/capability.json`);
|
|
}
|
|
const fragDir = path.join(dir, 'fragments');
|
|
if (fs.existsSync(fragDir)) {
|
|
for (const f of fs.readdirSync(fragDir)) {
|
|
if (LEAK.test(fs.readFileSync(path.join(fragDir, f), 'utf8'))) {
|
|
offenders.push(`capabilities/${id}/fragments/${f}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(offenders, [],
|
|
`capability sources embed ~/.claude install paths — these leak into the verbatim-copied capability-registry.cjs on non-Claude runtimes. Make the fragment path-free. Offenders: ${offenders.join(', ')}`);
|
|
});
|
|
|
|
test('generated capability-registry.cjs contains no ~/.claude install path', () => {
|
|
const reg = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), 'utf8');
|
|
const leakLines = reg.split(/\r?\n/).map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n);
|
|
assert.deepEqual(leakLines, [],
|
|
`capability-registry.cjs leaks ~/.claude install paths at line(s) ${leakLines.join(', ')} — the registry is copied verbatim to non-Claude runtimes (only workflow .md files are path-converted at install). Make the source capability fragment path-free.`);
|
|
});
|
|
});
|
|
|
|
test('every plan:pre planner contribution is injected generically (not per-capId hardcode)', () => {
|
|
// FIX C regression guard: plan-phase.md must inject planner contributions
|
|
// generically (by into == "planner") rather than only injecting a single
|
|
// hardcoded capId (e.g. "tdd"). A generic injection ensures any active
|
|
// plan:pre contribution with into=="planner" reaches the planner — including
|
|
// tdd, schema-gate, and security contributions.
|
|
//
|
|
// Heuristic: the planner prompt section must reference injecting where
|
|
// into == "planner" (or iterate contributions), AND must NOT rely solely
|
|
// on a single capId == "tdd" injection as the only planner contribution
|
|
// delivery mechanism.
|
|
const planPhase = readRepoFile('gsd-core/workflows/plan-phase.md');
|
|
|
|
// The file must contain a generic reference to into == "planner" contribution injection.
|
|
assert.match(
|
|
planPhase,
|
|
/into\s*==\s*["']planner["']/,
|
|
'plan-phase.md must inject planner contributions generically via into == "planner" ' +
|
|
'(not just a single hardcoded capId). Fix C regression: all active planner contributions must reach the planner.',
|
|
);
|
|
|
|
// Verify the file does NOT rely SOLELY on a hardcoded capId == "tdd" injection
|
|
// for the planner contribution. If only a tdd-specific injection exists (old form),
|
|
// the schema-gate and security contributions are silently dropped.
|
|
// We check: every occurrence of 'capId == "tdd"' contribution injection must be
|
|
// accompanied somewhere by a generic into=="planner" dispatch (already verified above).
|
|
// Additionally, the old exact tdd-only injection prose must not be the only delivery.
|
|
const onlyTddInjection = /\bRead from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "tdd"`\b/;
|
|
// If the old tdd-only prose still exists WITHOUT the generic into=="planner" prose,
|
|
// that's a regression. Since we already asserted into=="planner" exists, we just
|
|
// confirm the tdd-only prose is no longer the sole injection mechanism.
|
|
if (onlyTddInjection.test(planPhase)) {
|
|
// Old prose still present: acceptable only if generic prose is ALSO present (already asserted).
|
|
// Verify the into=="planner" injection appears NEAR the planner prompt (within 5000 chars of it).
|
|
const plannerPromptIdx = planPhase.indexOf('into == "planner"');
|
|
assert.ok(
|
|
plannerPromptIdx >= 0,
|
|
'plan-phase.md has tdd-only injection prose but no generic into=="planner" injection. ' +
|
|
'Remove the tdd-only injection and replace with generic contribution dispatch.',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every declared gate check.query returns a uniform boolean `block` field', () => {
|
|
// FIX A regression guard: every gate check command must return a top-level
|
|
// boolean `block` field so the host-loop dispatch can read a single consistent
|
|
// field regardless of which capability owns the gate.
|
|
//
|
|
// For each unique check.query declared in the registry's gate hooks, invoke
|
|
// the check command against a temp directory and assert the JSON output
|
|
// contains `block` as a boolean. Uses a minimal temp dir so the command
|
|
// returns quickly without real project state.
|
|
const os = require('node:os');
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-block-contract-'));
|
|
|
|
// Collect unique gate check.queries from the registry
|
|
const queries = new Set();
|
|
for (const cap of Object.values(registry.capabilities)) {
|
|
for (const gate of cap.gates || []) {
|
|
if (gate.check && gate.check.query) queries.add(gate.check.query);
|
|
}
|
|
}
|
|
assert.ok(queries.size > 0, 'Registry must declare at least one gate check.query');
|
|
|
|
const gsdTools = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
const failures = [];
|
|
|
|
for (const query of [...queries].sort()) {
|
|
let rawOut = '';
|
|
try {
|
|
// Invoke with --raw (the real dispatch form used by the host loop).
|
|
// Most commands accept a phase number and return valid JSON even when
|
|
// no real project state exists.
|
|
rawOut = execFileSync(
|
|
process.execPath,
|
|
[gsdTools, 'check', query, '1', '--raw'],
|
|
{ cwd: tmpDir, encoding: 'utf-8', timeout: 10000 },
|
|
);
|
|
const parsed = JSON.parse(rawOut.trim());
|
|
if (typeof parsed.block !== 'boolean') {
|
|
failures.push(
|
|
`check ${query}: returned JSON without a boolean \`block\` field ` +
|
|
`(got: ${JSON.stringify(parsed.block)}, type: ${typeof parsed.block}). ` +
|
|
`Add \`block\` to the command's output per the uniform gate contract.`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
// If it threw because the command required a different arg shape, try with a path
|
|
try {
|
|
rawOut = execFileSync(
|
|
process.execPath,
|
|
[gsdTools, 'check', query, tmpDir, '--raw'],
|
|
{ cwd: tmpDir, encoding: 'utf-8', timeout: 10000 },
|
|
);
|
|
const parsed = JSON.parse(rawOut.trim());
|
|
if (typeof parsed.block !== 'boolean') {
|
|
failures.push(
|
|
`check ${query}: returned JSON without a boolean \`block\` field ` +
|
|
`(got: ${JSON.stringify(parsed.block)}, type: ${typeof parsed.block}).`,
|
|
);
|
|
}
|
|
} catch (err2) {
|
|
failures.push(
|
|
`check ${query}: command failed or returned non-JSON output. ` +
|
|
`Error: ${err2 instanceof Error ? err2.message : String(err2)}. ` +
|
|
`Stdout: ${rawOut.slice(0, 200)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up temp dir
|
|
cleanup(tmpDir);
|
|
|
|
assert.deepEqual(
|
|
failures, [],
|
|
`Gate check commands must all return a top-level boolean \`block\` field:\n ${failures.join('\n ')}`,
|
|
);
|
|
});
|
|
});
|