Files
msd-core/tests/config-schema.property.test.cjs
Tom Boucher ff4a57b78c chore(#1671): migrate the remaining 13 LARGE/XL workflows to the fragment model — Phase 6.3 (#3030)
* chore(#2994): fragmentize progress.md forensic audit onto the fragment model

Extract the --forensic-gated forensic_audit step to
workflows/progress/steps/forensic-audit.md behind a section marker, and
repair progress.md's init line to forward --forensic so the atom is
actually true in production rather than only under direct CLI tests.

progress.md shrinks 32630 -> 27207 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize the four manifest-wired workflows

new-project, quick, new-milestone and progress each already had a
dedicated cmdInit* entry point but zero marked sections. Extract nine
gated bodies to workflows/<wf>/steps/ behind section markers and repair
each init line to forward its flags.

Fold --full into the discuss/research/validate facts inside cmdInitQuick
so the when= grammar never sees an OR, per the chunked-mode precedent.

Fixes found while working, per the no-defer rule:
- cmdInitProgress passed no phase info to buildSectionManifestField, so
  state:phase-mvp-mode was permanently false — an atom in the vocabulary
  whose fact could never be computed.
- the quick init router folded flag tokens into the free-text
  description, which the new forwarding would have corrupted.
- a #2508 dispatch note was nested inside quick.md's Agent(prompt=)
  fence, leaking orchestrator guidance into the subagent prompt.
- progress.md had a 3-vs-4 backtick outer-fence imbalance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize verify-work.md and admit state:ui-phase-active

Wire cmdInitVerifyWork to buildSectionManifestField — it was a dedicated
entry point that never emitted a manifest — and mark two sections.

state:ui-phase-active folds (plan:pre hooks include an active ui step) OR
(the phase dir holds a *-UI-SPEC.md) into one boolean in init.cts, so the
grammar still sees a single operator-free atom. The inner Playwright-MCP
check stays as prose inside the fragment: it is live session state and no
init seam can precompute it.

The MVP false-branch note is a real fallback, not redundant prose, so it
sits outside the marker — gating it away would delete the text needed
precisely when MVP mode is off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): follow moved workflow content in drift guards

Retarget every guard that asserted on content this branch moved into
workflows/<wf>/steps/, mirroring 815b3d897. Each retargeted assertion was
verified to still fail when its step file is blanked, so none was
weakened into vacuity.

Three assertions in verify-mvp-uat were genuinely red. Three more were
worse than red — passing for the wrong reason:
- quick-commit-boundary and worktree-cleanup anchored on indexOf('Step
  5.6'), which matched a later cross-reference and sliced 16069 chars
  that coincidentally held the asserted substrings. Replaced with an
  expandWorkflowSections helper that splices step content back in place.
- phase6-review-capabilities lost its end boundary and widened to EOF.
- playwright-ui-verify matched 'UI' in an unrelated bullet and 'fall
  back' in a subagent-dispatch line after the real content moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize code-review and complete-milestone, admit three atoms

Add dedicated cmdInitCodeReview and cmdInitCompleteMilestone entry points
alongside the shared generic ones rather than modifying them — init.phase-op
and init.manager carry a CRITICAL blast radius (179 dependents, 24
processes) and stay byte-identical for their other callers.

Admit flag:--fix, state:fallow-enabled and state:git-create-tag, each with
a consuming section and a fact its own entry point computes.

Both sections had the resolver-in-body hazard: the fallow config-gate and
the git.create_tag check each sat inside the very block being gated, so
gating would have disabled the resolver that decides the gate. Both are
hoisted into init and the bodies now consume the resolved fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): retarget code-review and milestone drift guards, fix two red tests

Retarget guards that asserted on content moved into steps/, proving
non-vacuity by blanking each step file and confirming failure.

Also fixes two genuinely red tests found while working, per the no-defer
rule:
- workflow-fragments' frozen-vocabulary lock was missing
  state:ui-phase-active, so commit 7ef7f8336 shipped red. Lint and build
  both passed over it, which is why neither is sufficient verification.
- code-review's quick.md capability-hook assertion carried a stale
  delimiter after the 18ff35d20 extraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize autonomous.md and admit state:plan-strategy-converge

Five sections share one atom, the pattern plan-phase already uses for
flag:--research-phase. The atom folds --converge OR --cross-ai into a
single boolean in cmdInitAutonomous so the grammar stays operator-free.

cmdInitAutonomous is additive; init.milestone-op, init.manager and
init.phase-op are untouched and still consumed. The $PLAN_STRATEGY bash
resolver is deliberately retained — ungated local-planning bullets still
read it, so the init-side fact supplements it rather than replacing it.

converge-fail-fast required splitting one bash fence so the always-run
CONVERGENCE_ARGS construction stays outside the marker. All three
flag-absent fallbacks were left outside their markers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize review and discuss-phase-assumptions

Admit state:reviewer-instances-configured (two peripheral notes share it;
the core reviewer-lane dispatch stays unmarked — it is the workflow's
primary always-evaluated logic, not an optional branch) and
state:auto-advance-active, which folds --auto OR two config keys into one
boolean so the grammar stays operator-free.

discuss-phase-assumptions was the highest-risk edit in this PR. Its
auto_advance step is a full if/elif/else; gating it whole would have
deleted the flag-absent fallback needed exactly when --auto is off. Split
verified exact: resolvers 636-651 and the 'End here' fallback 668-669 both
stay outside the marker; only 653-667 is gated.

Adds emitted-drift acks for the two files that grew — review.md (+55 B)
and autonomous.md (+737 B from 80799211c, which had none and would have
red-gated the push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize docs-update, update, transition and new-milestone Part A

Completes the 13-workflow rollout. Three of these had no init call at all
and gained a dedicated entry point plus their first gsd_run query line.

Admits state:is-monorepo and adds state:next-channel, state:workstream-active
and state:flat-mode. Vocabulary 26 -> 30 atoms.

Part A of new-milestone applies when NO workstream is active — the negation
of state:workstream-active. Rather than teach the grammar negation, which is
the Greenspun drift the frozen list exists to prevent, it gets a separate
positively-phrased atom whose fact is the inverse. Part B, which always runs,
stays outside the marker.

flag:--verify-only is deliberately NOT admitted: docs-update has no
contiguous purely-additive region for it, and an atom without a consuming
section is dead vocabulary. Evidence recorded in the slice report.

update.md reuses its existing resolved $GSD_TOOLS rather than prepending the
canonical preamble, which would have clobbered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): stop automated-ui-verification re-resolving its own gate, retire dead vocabulary

Two defects the new tests caught.

The automated-ui-verification step re-ran gsd_run loop render-hooks and
recomputed UI_PHASE_ACTIVE inside a body that is only read when that fact
is already true — the circular self-disabling pattern this design forbids,
introduced by 3c654b168. cmdInitVerifyWork now exposes ui_phase_active and
the step consumes it. Its launcher preamble goes too: no gsd_run remains.
The Playwright-MCP check stays as prose — that is live session state.

Dead vocabulary predating this PR: flag:--full and state:needs-codebase-map
were admitted with a gate-1 claim that never materialized. flag:--full is
removed, redundant once quick folds it into discuss/research/validate.
state:needs-codebase-map gets the real consumer it always lacked, gating
new-project's codebase-map offer. Vocabulary 30 -> 29, and no atom is now
without a consuming section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): add the atom-admission, inversion and resolver-hoist gates

The two existing parity guards prove vocabulary/predicate symmetry but
never that a fact is computed — an atom no cmdInit* assembles evaluates
false forever. These close that hole:

- per-atom satisfiability for all 29 atoms, plus an anti-vacuity assertion
  so the loop cannot silently cover zero atoms
- dead-vocabulary check against the shipped manifest
- inversion guard: the flag-absent fallbacks in discuss-phase-assumptions
  and verify-work must stay outside their markers
- data-driven resolver-hoist guard over the shipped manifest, so a future
  extraction cannot reintroduce the circular class
- compound-fold coverage (--full, --cross-ai, --rc, config-only --auto)
- null-vs-[] degraded/computed distinction, and flag value shapes

Also repairs the frozen-vocabulary lock, which was stale and red for the
seven atoms earlier commits on this branch shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#2994): add changeset for the fragment-model rollout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): cite the issue on the two new allow-test-rule exemptions

ADR-456 requires an issue ref on the same line as the annotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#2994): correct the atom-count claims after retiring flag:--full

The vocabulary doc comments still said 30 entries; it is 29 since
flag:--full was removed as dead vocabulary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): dedupe the phase-fallback block and harden --ws parsing

Review findings.

MAJOR: the three new init entry points each pasted a verbatim copy of the
guardedFindPhase/guardedGetRoadmapPhase fallback, taking the repo from four
copies to seven — DEFECT.GENERATIVE-FIX. Extracted applyRoadmapFallback and
folded six of the seven; each call site keeps its own field-set via a
closure. Duplication removed rather than papered over with a parity test.
cmdInitPhaseOp stays out: its fallback omits has_reviews, so it is not a
byte-identical copy, and it is CRITICAL-radius.

LOW, pre-existing: GSD_WS captured [^[:space:]]+ and expands unquoted, so a
workstream name holding glob metacharacters would expand against the
filesystem. Narrowed to [A-Za-z0-9._-]+. The unquoted expansion is kept —
it must word-split into two args and vanish when empty.

Also restores the vocabulary ordering convention, and fixes a masked test
bug the mandated run surfaced: the flag-forwarding guard checked only the
first init line per workflow, but new-milestone has two, so a real failure
was reporting exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): drop the stale new-milestone emitted-drift ack

new-milestone.md was acked for a +406 B growth measured against an
intermediate commit. Net against origin/next it SHRANK by 8 bytes, so
nothing needed the ack and it explained nothing — which the differential
attribution check reports as a stale acknowledgment, not a pass.

update.md's entry stays: it genuinely grew +703 B.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): resolve the 15 failures from the full matrix run

All 15 were real and identical on both lanes.

REAL REGRESSION: autonomous.md hit 41479 chars against the #2196 guard's
40960 cap — a CHARS cap distinct from the LARGE tier byte cap, which the
five section stubs pushed it over. Extracted the 3a.5 UI Design Contract
body to references/; now 39968 chars, and the file nets -795 B vs base, so
its growth ack is deleted rather than left stale.

REAL DEFECT: docs referenced /gsd-transition, which is not a live
registered command. Reworded.

STALE FIXTURE: the emission byte-identity test hardcoded two marked
workflows; this branch legitimately marks fifteen. Fixture corrected — the
source was right.

The rest were drift guards over the eight workflows the earlier sweep did
not cover, retargeted at where the content now lives with non-vacuity
proven by blanking each step file and confirming failure. The GSD_WS
forwarding guard was checked as a possible real break and is not one: the
charclass narrowing is intact and forwarding works end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): drop the ack for a newly-added reference file

A new file's emitted ripple is attributable to the diff that adds it, so
the acknowledgment explained nothing and the differential check reports it
as stale. Removing the last entry removes the fragment — an empty one
signals nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): retarget the UI-contract guards and clear two transitive advisories

The §3a.5 extraction that brought autonomous.md under the #2196 char cap
moved its body to references/autonomous-ui-design-contract.md, so ten
guards in autonomous-ui-steps and check-ui-safety-gate were asserting it
against the host. Retargeted via a combined read, each proven non-vacuous
by blanking the reference file and confirming failure.

This class had already bitten twice on this branch because each sweep was
scoped to the workflows touched at that moment, so this one was
exhaustive: ~70 test files across all 13 workflows, zero further broken or
vacuous assertions found.

Also clears two high transitive advisories the matrix flagged on one lane
— fast-uri GHSA-7p8r-x3mc-p8w7 and three ip-address SSRF/trust-boundary
issues. Both pre-date this branch: package-lock.json was untouched until
now, so the production tree was byte-identical to the base. Lockfile-only,
package.json unchanged, verified against a real npm ci install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): backfill changeset pr number to 3030

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:59:58 -04:00

1133 lines
50 KiB
JavaScript

'use strict';
/**
* Property-based tests for config-schema.cjs
*
* Module: gsd-core/bin/lib/config-schema.cjs
* Exported: isValidConfigKey(keyPath) -> boolean
* VALID_CONFIG_KEYS: Set<string>
* RUNTIME_STATE_KEYS: Set<string>
* DYNAMIC_KEY_PATTERNS: Array<{ test(key): boolean, ... }>
*
* Properties tested:
* (a) isValidConfigKey never throws regardless of input type/content
* (b) isValidConfigKey(key) is true for every key in VALID_CONFIG_KEYS
* (c) isValidConfigKey(key) is true for every key in RUNTIME_STATE_KEYS
* (d) Robustness: null/undefined/NaN/control-chars/binary never throw
* (e) Arbitrary garbage strings return false (not throw) from isValidConfigKey
*/
const { describe, test, before, after } = 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 fc = require('./helpers/fast-check-setup.cjs');
const { cleanup } = require('./helpers.cjs');
const {
isValidConfigKey,
isCapabilityConfigKey,
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
} = require('../gsd-core/bin/lib/config-schema.cjs');
describe('config-schema: isValidConfigKey properties', () => {
// (a) Never throws on any input
test('property: isValidConfigKey never throws on hostile inputs', () => {
fc.assert(
fc.property(
fc.oneof(
fc.constant(null),
fc.constant(undefined),
fc.constant(NaN),
fc.constant(Infinity),
fc.constant(-Infinity),
fc.constant(0),
fc.constant(''),
fc.constant('\x00'),
fc.constant('\n\r\t'),
fc.string({ unit: 'binary', maxLength: 100 }),
fc.string({ unit: 'grapheme-composite', maxLength: 100 }),
fc.constant([]),
fc.constant({}),
fc.boolean(),
fc.string({ maxLength: 100 })
),
(input) => {
assert.doesNotThrow(
() => isValidConfigKey(input),
`isValidConfigKey threw on input: ${JSON.stringify(input)}`
);
}
)
);
});
// (b) Every key in VALID_CONFIG_KEYS returns true
test('all VALID_CONFIG_KEYS entries are recognized as valid', () => {
for (const key of VALID_CONFIG_KEYS) {
assert.equal(
isValidConfigKey(key),
true,
`Expected isValidConfigKey(${JSON.stringify(key)}) === true`
);
}
});
// (c) Every key in RUNTIME_STATE_KEYS returns true
test('all RUNTIME_STATE_KEYS entries are recognized as valid', () => {
for (const key of RUNTIME_STATE_KEYS) {
assert.equal(
isValidConfigKey(key),
true,
`Expected isValidConfigKey(${JSON.stringify(key)}) === true (runtime state key)`
);
}
});
// (d+e) Robustness: hostile strings return boolean (not throw)
test('property: isValidConfigKey always returns a boolean for any string', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 200 }),
(key) => {
const result = isValidConfigKey(key);
assert.ok(
typeof result === 'boolean',
`isValidConfigKey must return boolean, got ${typeof result} for ${JSON.stringify(key)}`
);
}
)
);
});
test('property: binary/control-char strings return false (not throw)', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string({ unit: 'binary', maxLength: 100 }),
fc.string({ unit: 'grapheme-composite', maxLength: 100 })
),
(key) => {
// Either returns true (if it happens to match a valid key) or false
// It must NOT throw
let result;
assert.doesNotThrow(() => {
result = isValidConfigKey(key);
});
assert.ok(typeof result === 'boolean');
}
)
);
});
// Boundary: well-formed dotted paths that are NOT in the schema
test('property: plausible-but-invalid dotted paths return false', () => {
// Generate dot-separated alphanumeric paths that do not match known keys
const dotPath = fc.array(
fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/),
{ minLength: 3, maxLength: 5 }
).map((parts) => 'zz_unknown.' + parts.join('.'));
fc.assert(
fc.property(dotPath, (key) => {
// Must not throw
let result;
assert.doesNotThrow(() => {
result = isValidConfigKey(key);
});
// Result is a boolean
assert.ok(typeof result === 'boolean');
})
);
});
// Boundary: empty string is not a valid config key
test('empty string is not a valid config key', () => {
const result = isValidConfigKey('');
assert.equal(result, false, 'empty string must not be a valid config key');
});
// Boundary: null/undefined/number return false (not throw, not true)
test('null, undefined, number inputs return false', () => {
assert.equal(isValidConfigKey(null), false);
assert.equal(isValidConfigKey(undefined), false);
assert.equal(isValidConfigKey(42), false);
assert.equal(isValidConfigKey(NaN), false);
});
});
// ─── ADR-1244 D2: cwd-aware overlay config-key federation ─────────────────────
//
// Exercises every branch of the new _capabilityConfigSchema(cwd) path so the
// mutation suite (this is the file Stryker runs for config-schema) KILLS the
// added mutants: the `typeof cwd === 'string' && cwd` guard, the overlay
// loadRegistry({includeInstalled,cwd}) call, the `schema && typeof === 'object'`
// found-branch, the first-party fallback, and the cwd threading through
// isValidConfigKey. Uses a real overlay fixture (no test seam).
describe('config-schema: cwd-aware overlay federation (ADR-1244 D2)', () => {
const OVERLAY_KEY = 'workflow.cfgschema_overlay_gate';
// A known FIRST-PARTY capability config key (ui capability) — exercises the
// first-party fallback branch (no cwd → frozen registry configSchema).
const FIRST_PARTY_KEY = 'workflow.ui_phase';
const overlayCap = {
id: 'cfgschema-overlay', role: 'feature', version: '1.0.0', title: 'cfg overlay', description: 'x',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: ['cfgschema-overlay-skill'], agents: [], hooks: [],
config: { [OVERLAY_KEY]: { type: 'boolean', default: true, description: 'overlay-owned key' } },
steps: [], contributions: [], gates: [],
};
let withOverlay, withoutOverlay, sandboxHome, savedHome;
before(() => {
savedHome = process.env.GSD_HOME;
sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-home-'));
process.env.GSD_HOME = sandboxHome; // empty global overlay root + user-owned consent store
// realpath so the consent record's realpath(projectRoot) matches the loader's lookup.
withOverlay = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-proj-')));
fs.mkdirSync(path.join(withOverlay, '.planning'), { recursive: true }); // project-root marker
const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'cfgschema-overlay');
fs.mkdirSync(capDir, { recursive: true });
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf8');
// #1459: a PROJECT-scope overlay activates only with a committed ledger AND a user consent record
// on this machine. Write both so the cwd-aware federation behavior under test is exercised for a
// genuinely-installed+consented overlay (a forged in-repo ledger alone no longer activates it).
fs.writeFileSync(
path.join(withOverlay, '.gsd-capabilities.json'),
JSON.stringify({ version: '1', updatedAt: '2026-01-01T00:00:00Z', entries: {
'cfgschema-overlay': { id: 'cfgschema-overlay', version: '1.0.0', source: 's', integrity: 'sha512-cfg', files: [], sharedEdits: [] },
} }),
'utf8',
);
const trust = require('../gsd-core/bin/lib/capability-trust.cjs');
const consent = require('../gsd-core/bin/lib/capability-consent.cjs');
consent.recordProjectConsent({
gsdHome: sandboxHome, projectRoot: withOverlay, id: 'cfgschema-overlay',
integrity: 'sha512-cfg', disclosureSignature: trust.signatureForManifest(overlayCap, capDir),
contentHash: consent.bundleContentHash(capDir),
});
withoutOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-bare-'));
fs.mkdirSync(path.join(withoutOverlay, '.planning'), { recursive: true });
});
after(() => {
if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome;
cleanup(sandboxHome); cleanup(withOverlay); cleanup(withoutOverlay);
});
test('first-party fallback: a first-party capability config key is valid with no cwd', () => {
// Kills the fallback branch (return fp ... : {}) and the no-cwd path.
assert.equal(isCapabilityConfigKey(FIRST_PARTY_KEY), true);
assert.equal(isValidConfigKey(FIRST_PARTY_KEY), true);
});
test('overlay key is recognized only when the installing project cwd is supplied', () => {
// cwd with the overlay → true (kills cwd-guard, loadRegistry call, found-branch, hasOwnProperty)
assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withOverlay), true);
assert.equal(isValidConfigKey(OVERLAY_KEY, withOverlay), true);
// no cwd → first-party only → false (kills the cwd-true→fallback distinction)
assert.equal(isCapabilityConfigKey(OVERLAY_KEY), false);
assert.equal(isValidConfigKey(OVERLAY_KEY), false);
// cwd WITHOUT the overlay → loadRegistry returns base → false (cwd-correct)
assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withoutOverlay), false);
assert.equal(isValidConfigKey(OVERLAY_KEY, withoutOverlay), false);
});
test('a genuinely unknown key is invalid regardless of cwd', () => {
assert.equal(isCapabilityConfigKey('zz.not.a.key', withOverlay), false);
assert.equal(isValidConfigKey('zz.not.a.key', withOverlay), false);
});
test('non-string keyPath returns false even with a cwd (no throw)', () => {
assert.equal(isCapabilityConfigKey(null, withOverlay), false);
assert.equal(isCapabilityConfigKey(42, withOverlay), false);
});
});
// ---------------------------------------------------------------------------
// ADR-1244 Phase 4 — capability trust config keys
// ---------------------------------------------------------------------------
describe('capability trust config keys (ADR-1244 Phase 4)', () => {
const { CONFIG_DEFAULTS } = require('../gsd-core/bin/lib/configuration.cjs');
test('capabilities.strict_known_registries and capabilities.auto_update are valid central keys', () => {
assert.equal(isValidConfigKey('capabilities.strict_known_registries'), true);
assert.equal(isValidConfigKey('capabilities.auto_update'), true);
});
test('there is no capabilities.* wildcard — an unknown capabilities key is invalid', () => {
assert.equal(isValidConfigKey('capabilities.something_else'), false);
});
test('defaults: strict_known_registries is permissive (null) and auto_update is OFF (false)', () => {
assert.equal(CONFIG_DEFAULTS.capabilities.strict_known_registries, null);
assert.equal(CONFIG_DEFAULTS.capabilities.auto_update, false);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2986-config-schema-mutation-killers.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2986-config-schema-mutation-killers (consolidation epic #1969 B3 #1972)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2986: Layer-3 fault-detection audit found 4.62% Stryker mutation
* score on gsd-core/bin/lib/config-schema.cjs (6 killed, 124 survived).
* Surviving mutants document tests that "exercise paths" but don't
* "verify outputs" -- a polarity flip or predicate swap inside the lib
* passed every existing test.
*
* Sample surviving mutants from #2986:
* M1: `if (VALID_CONFIG_KEYS.has(keyPath)) return true;`
* -> `if (false) return true;`
* Killer: a test that asserts isValidConfigKey returns true for
* every member of VALID_CONFIG_KEYS. If VALID_CONFIG_KEYS.has is
* short-circuited to false, those keys would only be accepted if
* a DYNAMIC_KEY_PATTERN matches them -- and none of the static
* keys match any dynamic pattern by design.
*
* M2: `return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));`
* -> `return DYNAMIC_KEY_PATTERNS.every(p => p.test(keyPath));`
* Killer: a test that supplies a key matching ONE pattern but not
* every pattern. With `.every`, that key is rejected; with `.some`,
* accepted. The current dynamic-pattern set is mutually exclusive
* (e.g., `agent_skills.foo` matches the agent_skills regex but not
* review/features/claude_md_assembly/model_profile_overrides), so
* any single dynamic-key sample suffices.
*
* M3: `return true` -> `return false` on the early-return line
* Killer: a test that uses a known-valid static key and asserts
* the boolean true (not just "non-falsy" or "no throw"). A
* polarity flip turns the true into false; the assertion catches it.
*
* M4: `if (VALID_CONFIG_KEYS.has(keyPath)) return true;` -> remove the
* guard entirely (return DYNAMIC_KEY_PATTERNS.some(...) always).
* Killer: same as M1 -- static keys that don't match any dynamic
* pattern would be wrongly rejected.
*
* These tests exercise the lib's PUBLIC SURFACE (isValidConfigKey)
* with structured inputs and assert on typed boolean outputs. No regex
* on source code; no source-grep.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const {
VALID_CONFIG_KEYS,
DYNAMIC_KEY_PATTERNS,
isValidConfigKey,
} = require('../gsd-core/bin/lib/config-schema.cjs');
describe('Bug #2986: M1/M4 -- isValidConfigKey returns true for EVERY static key in VALID_CONFIG_KEYS', () => {
// Stryker mutants like `if (false) return true;` would silently flip
// every static key to "rejected" because none of the static keys match
// any dynamic pattern by design. This parameterized test is the
// mutation-kill equivalent for that branch.
for (const key of VALID_CONFIG_KEYS) {
test(`isValidConfigKey('${key}') === true`, () => {
assert.strictEqual(isValidConfigKey(key), true,
`static config key '${key}' must be accepted (catches Stryker mutant on the static-key fast path)`);
});
}
});
describe('Bug #2986: M2 -- DYNAMIC_KEY_PATTERNS.some semantic, not .every', () => {
// Each pattern has a representative key that matches ONLY that pattern
// (mutually exclusive with the others by design) AND is NOT a member of
// VALID_CONFIG_KEYS. The static-key fast-path returns true before
// DYNAMIC_KEY_PATTERNS.some() ever runs, so any rep key that's also in
// VALID_CONFIG_KEYS gives the M2 killer zero coverage for that pattern
// (#3005 CR: this caught features.thinking_partner, which IS in static).
// A reserved-prefix-style placeholder name is used for `features` so the
// dynamic path is the only way to reach `true`.
const patternRepresentatives = [
{ key: 'agent_skills.gsd-planner', topLevel: 'agent_skills' },
// #2797: `review.models.<slug>` was the `review` representative until that
// pattern was federated to the lane capabilities and removed from the central
// schema. `reviewer_instances` is the surviving `review` dynamic pattern —
// an instance is not a lane (ADR-2782 D8), so it stays central.
{ key: 'review.reviewer_instances.myinstance.cli', topLevel: 'review' },
{ key: 'features.some_dynamic_feature', topLevel: 'features' },
{ key: 'claude_md_assembly.blocks.intro', topLevel: 'claude_md_assembly' },
{ key: 'model_profile_overrides.codex.opus', topLevel: 'model_profile_overrides' },
];
for (const { key, topLevel } of patternRepresentatives) {
test(`isValidConfigKey('${key}') === true (matches '${topLevel}' pattern via dynamic path)`, () => {
// Invariant: the rep key MUST NOT be in the static set. Otherwise the
// static fast-path short-circuits and the dynamic-pattern .some() is
// never invoked, so a mutation removing this entry from
// DYNAMIC_KEY_PATTERNS would survive.
assert.strictEqual(VALID_CONFIG_KEYS.has(key), false,
`representative key '${key}' must NOT be in VALID_CONFIG_KEYS — otherwise the static fast-path masks the dynamic-pattern test (#3005 CR)`);
assert.strictEqual(isValidConfigKey(key), true,
`dynamic key '${key}' must be accepted via DYNAMIC_KEY_PATTERNS.some`);
// Verify mutual exclusivity: only one pattern matches this key.
const matchCount = DYNAMIC_KEY_PATTERNS.filter((p) => p.test(key)).length;
assert.strictEqual(matchCount, 1,
`mutual-exclusivity invariant: '${key}' must match exactly 1 pattern, matched ${matchCount}. ` +
`If this fails, dynamic-pattern overlap was introduced and the .some-vs-.every mutation killer breaks.`);
});
}
});
describe('Bug #2986: M3 -- polarity assertion (true is true, not just truthy)', () => {
// Stryker mutants that flip `return true` to `return false` are killed
// by strictEqual against the boolean true. assert.ok would tolerate any
// truthy value (e.g., a non-empty string returned by a different mutation).
test('isValidConfigKey returns the literal boolean true for static keys', () => {
const result = isValidConfigKey('model_profile');
assert.strictEqual(result, true);
assert.strictEqual(typeof result, 'boolean');
});
test('isValidConfigKey returns the literal boolean false for unknown keys', () => {
const result = isValidConfigKey('totally_unknown_key_xyz');
assert.strictEqual(result, false);
assert.strictEqual(typeof result, 'boolean');
});
test('isValidConfigKey returns false for a dynamic-pattern-shape key under a non-existent topLevel', () => {
// E.g., `unrelated.models.claude` syntactically resembles a dynamic
// pattern but no DYNAMIC_KEY_PATTERN owns the `unrelated` topLevel.
// A mutant that loosens the regex anchors would falsely accept this.
assert.strictEqual(isValidConfigKey('unrelated.models.claude'), false);
});
});
describe('Bug #2986: anchor-tightening (catches mutants that loosen ^ or $ in regexes)', () => {
// Each dynamic regex is anchored. Mutants that drop ^ or $ would
// accept too much. These keys differ from a valid one by ONE character
// beyond the documented shape; they must be rejected.
const overshoot = [
{ key: 'agent_skills.gsd-planner.extra', reason: 'agent_skills regex must not allow trailing dot-segment' },
{ key: 'agent_skills.', reason: 'agent_skills regex requires non-empty agent name' },
{ key: 'review.models.', reason: 'review.models regex requires non-empty cli name' },
{ key: 'features.bad name with spaces', reason: 'features regex disallows spaces' },
{ key: 'model_profile_overrides.codex.gpt5', reason: 'model_profile_overrides tier is enum-restricted to opus|sonnet|haiku' },
{ key: 'model_profile_overrides.codex', reason: 'model_profile_overrides requires .<tier> suffix' },
];
for (const { key, reason } of overshoot) {
test(`isValidConfigKey('${key}') === false -- ${reason}`, () => {
assert.strictEqual(isValidConfigKey(key), false,
`'${key}' must be rejected (catches anchor/charset-loosening mutants)`);
});
}
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-2527-settings-layers.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-2527-settings-layers (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* Feature test for #2527 — /gsd-settings expands to 22 settings grouped into
* six visual sections. Adds 8 new fields (pattern_mapper, tdd_mode, code_review,
* code_review_depth, ui_review, commit_docs, intel.enabled, graphify.enabled)
* and verifies each is present in the AskUserQuestion block, the update_config
* step, the confirmation table, the ~/.gsd/defaults.json save step, and the
* effective config-key validator.
*
* Closes: #2527
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md');
const {
VALID_CONFIG_KEYS,
isCentralConfigKey,
isValidConfigKey,
} = require('../gsd-core/bin/lib/config-schema.cjs');
const NEW_FIELDS = [
'workflow.pattern_mapper',
'workflow.tdd_mode',
'workflow.code_review',
'workflow.code_review_depth',
'workflow.ui_review',
'commit_docs',
'intel.enabled',
'graphify.enabled',
];
const CENTRAL_NEW_FIELDS = [
'commit_docs',
];
const CAPABILITY_OWNED_NEW_FIELDS = NEW_FIELDS.filter((field) => !CENTRAL_NEW_FIELDS.includes(field));
const SECTION_HEADERS = ['Planning', 'Execution', 'Docs & Output', 'Features', 'Model & Pipeline', 'Misc'];
/**
* Match a dotted config-key path inside a block of text. Falls back to a
* simple substring check for single-segment keys; for nested keys, requires
* each segment to appear in order within a bounded window so distinct fields
* (e.g., intel.enabled vs graphify.enabled) cannot collapse to the same leaf.
*/
function hasPathLike(block, field) {
const parts = field.split('.');
if (parts.length === 1) return block.includes(parts[0]);
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(escaped.join('[\\s\\S]{0,600}'), 'i');
return pattern.test(block);
}
describe('#2527: settings.md adds grouped settings layers', () => {
let content;
before(() => {
content = fs.readFileSync(SETTINGS_PATH, 'utf-8');
});
describe('Acceptance: all 8 new fields present in AskUserQuestion block', () => {
for (const field of NEW_FIELDS) {
test(`settings.md mentions ${field}`, () => {
assert.ok(
content.includes(field),
`settings.md must reference the config key "${field}" in its AskUserQuestion/update_config step`
);
});
}
});
describe('Acceptance: section headers applied', () => {
for (const section of SECTION_HEADERS) {
test(`settings.md declares a "${section}" section header`, () => {
// The convention for grouping AskUserQuestion items is a markdown section heading
// of the form "### <Section>" inside the present_settings step.
const heading = new RegExp(`^#{2,4}\\s+${section.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'm');
assert.ok(
heading.test(content),
`settings.md must declare a "${section}" section header to group questions`
);
});
}
});
describe('Acceptance: update_config step includes all new fields', () => {
test('update_config step references every new field', () => {
const updateMatch = content.match(/<step name="update_config">[\s\S]*?<\/step>/);
assert.ok(updateMatch, 'settings.md must have an update_config step');
const updateBlock = updateMatch[0];
for (const field of NEW_FIELDS) {
// Keys may appear as nested JSON (e.g., "pattern_mapper" under workflow).
// Use hasPathLike so distinct dotted keys (e.g., intel.enabled,
// graphify.enabled) cannot share a single "enabled" occurrence.
assert.ok(
hasPathLike(updateBlock, field),
`update_config step must write "${field}"`
);
}
});
});
describe('Acceptance: save_as_defaults step includes all new fields', () => {
test('save_as_defaults step references every new field', () => {
const defaultsMatch = content.match(/<step name="save_as_defaults">[\s\S]*?<\/step>/);
assert.ok(defaultsMatch, 'settings.md must have a save_as_defaults step');
const block = defaultsMatch[0];
for (const field of NEW_FIELDS) {
assert.ok(
hasPathLike(block, field),
`save_as_defaults step must persist "${field}" into ~/.gsd/defaults.json`
);
}
});
});
describe('Acceptance: confirmation display includes all new fields', () => {
test('confirm step table lists every new setting by name', () => {
const confirmMatch = content.match(/<step name="confirm">[\s\S]*?<\/step>/);
assert.ok(confirmMatch, 'settings.md must have a confirm step');
const block = confirmMatch[0];
const expectedLabels = [
'Pattern Mapper',
'TDD Mode',
'Code Review',
'Code Review Depth',
'UI Review',
'Commit Docs',
'Intel',
'Graphify',
];
for (const label of expectedLabels) {
assert.ok(
block.includes(label),
`confirm step table must display "${label}"`
);
}
});
});
describe('Acceptance: all 8 new fields accepted by the config validator', () => {
for (const field of NEW_FIELDS) {
test(`config validator accepts ${field}`, () => {
assert.ok(
isValidConfigKey(field),
`${field} must be accepted so config-set can write it`
);
});
}
});
describe('Acceptance: migrated capability fields are no longer central config keys', () => {
for (const field of CAPABILITY_OWNED_NEW_FIELDS) {
test(`${field} is capability-owned, not central-schema residue`, () => {
assert.equal(
isCentralConfigKey(field),
false,
`${field} must be owned by the capability registry instead of the central schema`
);
assert.equal(
VALID_CONFIG_KEYS.has(field),
false,
`${field} must not be duplicated in VALID_CONFIG_KEYS after Phase 6 migration`
);
});
}
});
describe('Acceptance: still-central settings remain in VALID_CONFIG_KEYS', () => {
for (const field of CENTRAL_NEW_FIELDS) {
test(`VALID_CONFIG_KEYS contains central setting ${field}`, () => {
assert.ok(
VALID_CONFIG_KEYS.has(field),
`${field} is not a migrated capability key and must remain in VALID_CONFIG_KEYS`
);
});
}
});
describe('Acceptance: code_review_depth is conditional on code_review=on', () => {
test('settings.md documents conditional visibility for code_review_depth', () => {
// Must explicitly note that code_review_depth only appears when code_review is on.
const conditionalRegex = /code_review_depth[\s\S]{0,400}(only|conditional|when|if)[\s\S]{0,80}code_review/i;
assert.ok(
conditionalRegex.test(content) ||
/code_review\s*=\s*on[\s\S]{0,400}code_[…]*depth/i.test(content),
'settings.md must document that code_review_depth is only shown when code_review is on'
);
});
});
describe('Negative: settings.md constrains code_review_depth options', () => {
test('settings.md restricts code_review_depth to a known option set', () => {
// Depth accepts string values (quick|standard|deep). config-set does not
// block arbitrary strings at the value level today; instead settings.md
// constrains the AskUserQuestion options to the valid set so users
// cannot pick "bogus" via the interactive flow.
const depthOptionsRegex =
/code_review_depth[\s\S]{0,800}(quick|standard|deep|surface)/i;
assert.ok(
depthOptionsRegex.test(content),
'settings.md must constrain code_review_depth options to a known set'
);
});
});
describe('Negative: config-set rejects an unknown key path', () => {
test('config-set workflow.code_review_bogus_key fails', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const bad = runGsdTools(['config-set', 'workflow.code_review_bogus_key', 'x'], tmpDir);
assert.ok(!bad.success, 'config-set on an unknown key must fail');
});
});
describe('Acceptance: all 6 section headers are used as header: field on first question in each section', () => {
test('the header field appears for each section in the AskUserQuestion block', () => {
// Map user-visible section names to the short `header:` strings used in AskUserQuestion.
// settings.md uses abbreviated headers (max 12 chars). Verify at least one header
// per section-intent appears on a question.
const requiredHeaders = [
/header:\s*"Model"/, // Model & Pipeline opener
/header:\s*"Research"/, // Planning opener (first Planning-section question)
/header:\s*"Pattern Mapper"|header:\s*"Patterns"/, // new Planning addition
/header:\s*"Verifier"/, // Execution existing
/header:\s*"TDD"/, // new Execution
/header:\s*"Code Review"/, // new Execution
/header:\s*"UI Review"/, // new Execution
/header:\s*"Commit Docs"/, // new Docs & Output
/header:\s*"Intel"/, // new Features
/header:\s*"Graphify"/, // new Features
];
for (const re of requiredHeaders) {
assert.ok(re.test(content), `settings.md must include an AskUserQuestion header matching ${re}`);
}
});
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3210-fallow-integration.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3210-fallow-integration (consolidation epic #1969 B3 #1972)", () => {
// allow-test-rule: source-text-is-the-product (see #3210)
// This test validates workflow/agent/config contracts stored in shipped .md/.ts/.cjs
// artifacts. Source text is the runtime product for those surfaces.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
// N2: single helper — on macOS os.tmpdir() already returns /private/tmp; the
// existsSync guard is kept only as defense-in-depth fallback.
function getWritableTmp() {
const candidates = ['/private/tmp', '/tmp', os.tmpdir()];
return candidates.find((dir) => {
try {
fs.accessSync(dir, fs.constants.W_OK);
return true;
} catch {
return false;
}
});
}
describe('feat-3210: fallow integration module', () => {
test('normalizes structural findings from a fallow report', () => {
const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs');
const fixture = JSON.parse(
fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-findings.json'), 'utf8'),
);
const normalized = normalizeFallowReport(fixture);
// Counts derived from real schema fixture fields
const expectedUnused = fixture.dead_code.unused_exports.length;
const expectedUnusedFiles = fixture.dead_code.unused_files.length;
const expectedCircular = fixture.dead_code.circular_dependencies.length;
const expectedDuplicates = fixture.duplication.clone_groups.length;
assert.deepStrictEqual(normalized.summary, {
unused_exports: expectedUnused,
unused_files: expectedUnusedFiles,
duplicates: expectedDuplicates,
circular_dependencies: expectedCircular,
total: 4,
});
assert.strictEqual(normalized.findings.length, 4);
});
test('falls back to node_modules/.bin/fallow when PATH does not contain fallow', () => {
const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs');
// N2: use shared helper
const baseTmp = getWritableTmp();
const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-bin-'));
const binDir = path.join(tmp, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const fallowPath = path.join(binDir, 'fallow');
fs.writeFileSync(fallowPath, '#!/usr/bin/env sh\n');
if (process.platform !== 'win32') fs.chmodSync(fallowPath, 0o755);
const resolved = resolveFallowBinary({ cwd: tmp, envPath: '' });
assert.strictEqual(resolved, fallowPath);
cleanup(tmp);
});
// H6: replaced wholesale win32 skip with platform-adapted assertion
test('ignores non-executable PATH candidate on non-Windows; prefers .cmd over bare extensionless on Windows', () => {
const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs');
// N2: use shared helper
const baseTmp = getWritableTmp();
if (process.platform === 'win32') {
// H6: Windows — .cmd extension candidate must be preferred over bare extensionless file
const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-win-'));
try {
const pathDir = path.join(tmp, 'bin');
fs.mkdirSync(pathDir, { recursive: true });
const bareFile = path.join(pathDir, 'fallow');
const cmdFile = path.join(pathDir, 'fallow.cmd');
fs.writeFileSync(bareFile, '@echo off\r\n');
fs.writeFileSync(cmdFile, '@echo off\r\n');
const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir });
assert.strictEqual(
resolved,
cmdFile,
'Windows: .cmd candidate must be preferred over bare extensionless file',
);
} finally {
cleanup(tmp);
}
} else {
// H6: non-Windows — non-executable file in PATH must be ignored
const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-nonexec-'));
try {
const pathDir = path.join(tmp, 'bin');
fs.mkdirSync(pathDir, { recursive: true });
const nonExec = path.join(pathDir, 'fallow');
fs.writeFileSync(nonExec, '#!/usr/bin/env sh\n');
fs.chmodSync(nonExec, 0o644);
const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir });
assert.strictEqual(resolved, null);
} finally {
cleanup(tmp);
}
}
});
test('normalizes empty fallow report to zero findings', () => {
const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs');
const fixture = JSON.parse(
fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-empty.json'), 'utf8'),
);
const normalized = normalizeFallowReport(fixture);
assert.deepStrictEqual(normalized.summary, {
unused_exports: 0,
unused_files: 0,
duplicates: 0,
circular_dependencies: 0,
total: 0,
});
assert.deepStrictEqual(normalized.findings, []);
});
test('throws actionable error when fallow is enabled but binary is unavailable', () => {
const { requireFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs');
// N2: use shared helper
const baseTmp = getWritableTmp();
const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-missing-'));
assert.throws(
() => requireFallowBinary({ cwd: tmp, envPath: '' }),
/install fallow via `npm install -D fallow` or `cargo install fallow`/,
);
cleanup(tmp);
});
// M5: edge-case fixture — line:0 preservation, unicode path, single-instance clone_group, 3-file cycle
test('normalizes edge-case fixture: line:0 preservation, unicode path, single-instance clone_group, 3-file cycle', () => {
const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs');
const fixture = JSON.parse(
fs.readFileSync(
path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-edge-cases.json'),
'utf8',
),
);
// Real schema: unused_export with line:0 — must survive without coercion
assert.strictEqual(fixture.dead_code.unused_exports.length, 1);
assert.strictEqual(fixture.dead_code.unused_exports[0].line, 0, 'edge-case fixture: line must be 0');
// unicode file path is preserved in fixture
assert.ok(
fixture.dead_code.unused_exports[0].path.includes('café'),
'edge-case fixture: unicode file path must be present',
);
// single-instance clone_group (related_file normalizes to '')
assert.strictEqual(fixture.duplication.clone_groups.length, 1);
assert.strictEqual(fixture.duplication.clone_groups[0].instances.length, 1);
// 3-file circular dependency cycle
assert.strictEqual(fixture.dead_code.circular_dependencies.length, 1);
assert.strictEqual(
fixture.dead_code.circular_dependencies[0].files.length,
3,
'edge-case: files array must have exactly 3 entries',
);
// normalization round-trips without throwing
const normalized = normalizeFallowReport(fixture);
// 1 unused_export + 0 unused_files + 1 circular_dep + 1 clone_group = 3
const expectedTotal =
fixture.dead_code.unused_exports.length +
fixture.dead_code.unused_files.length +
fixture.dead_code.circular_dependencies.length +
fixture.duplication.clone_groups.length;
assert.strictEqual(normalized.findings.length, expectedTotal);
assert.strictEqual(normalized.summary.total, expectedTotal);
// line:0 survives normalization
const unicodeFinding = normalized.findings.find(
(f) => typeof f.file === 'string' && f.file.includes('café'),
);
assert.ok(unicodeFinding, 'unicode file path must survive normalization round-trip');
assert.strictEqual(unicodeFinding.line, 0, 'line:0 must not be coerced to null');
// single-instance clone_group: related_file must be ''
const dupFinding = normalized.findings.find((f) => f.type === 'duplicate_block');
assert.ok(dupFinding, 'duplicate_block finding must exist');
assert.strictEqual(dupFinding.related_file, '', 'single-instance clone_group: related_file must be empty string');
});
});
describe('feat-3210: H1 - line:0 preservation', () => {
test('normalizeFallowReport preserves line:0 for unused_export (not coerced to null)', () => {
const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs');
const report = {
dead_code: {
unused_exports: [{ path: 'src/a.ts', export_name: 'foo', line: 0 }],
},
};
const normalized = normalizeFallowReport(report);
assert.strictEqual(normalized.findings[0].line, 0, 'line:0 must not be coerced to null via ||');
});
test('normalizeFallowReport preserves line:0 for duplicate_block instances[0].start_line (not coerced to null)', () => {
const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs');
const report = {
duplication: {
clone_groups: [
{ instances: [{ file: 'src/a.ts', start_line: 0 }, { file: 'src/b.ts', start_line: 5 }] },
],
},
};
const normalized = normalizeFallowReport(report);
assert.strictEqual(normalized.findings[0].line, 0, 'start_line:0 must not be coerced to null via ||');
});
});
describe('feat-3210: M2 - node_modules/.bin resolution order', () => {
test('resolveFallowBinary prefers node_modules/.bin over PATH when both exist', () => {
const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs');
// N2: use shared helper
const baseTmp = getWritableTmp();
const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-order-'));
try {
// local node_modules/.bin/fallow
const binDir = path.join(tmp, 'node_modules', '.bin');
fs.mkdirSync(binDir, { recursive: true });
const localFallow = path.join(binDir, 'fallow');
fs.writeFileSync(localFallow, '#!/usr/bin/env sh\necho local\n');
if (process.platform !== 'win32') fs.chmodSync(localFallow, 0o755);
// PATH fallow (a different file)
const pathDir = path.join(tmp, 'pathbin');
fs.mkdirSync(pathDir, { recursive: true });
const pathFallow = path.join(pathDir, 'fallow');
fs.writeFileSync(pathFallow, '#!/usr/bin/env sh\necho path\n');
if (process.platform !== 'win32') fs.chmodSync(pathFallow, 0o755);
const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir });
assert.strictEqual(resolved, localFallow, 'node_modules/.bin/fallow must win over PATH fallow');
} finally {
cleanup(tmp);
}
});
});
describe('feat-3210 / #1012: code-review workflow invokes fallow with the real CLI', () => {
// allow-test-rule: source-text-is-the-product — code-review.md IS the workflow the orchestrator (see #3210)
// executes; its fallow invocation is the product surface.
const workflowSrc = fs.readFileSync(
path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'),
'utf8',
);
// #2994: the actual fallow invocation (audit --format json, --changed-since,
// --max-crap, verdict-based exit handling) moved to a marker-gated step file
// (gsd-core/workflows/code-review/steps/structural-pre-pass.md, when=
// "state:fallow-enabled") — read it for the invocation-specific assertions
// below; the negative "removed flags" assertion checks BOTH files.
const fallowStepSrc = fs.readFileSync(
path.join(ROOT, 'gsd-core', 'workflows', 'code-review', 'steps', 'structural-pre-pass.md'),
'utf8',
);
const combinedSrc = workflowSrc + '\n' + fallowStepSrc;
test('uses audit --format json and --quiet (real fallow 2.x flags)', () => {
assert.ok(
fallowStepSrc.includes('audit --format json'),
'workflow must invoke: audit --format json',
);
assert.ok(
fallowStepSrc.includes('--quiet'),
'workflow must pass --quiet to suppress progress output',
);
});
test('does NOT use removed flags: --json , --profile, --stdin-files', () => {
assert.ok(
!combinedSrc.includes('--json '),
'workflow must not use old --json flag (note trailing space to avoid matching --format json)',
);
assert.ok(
!combinedSrc.includes('--profile'),
'workflow must not use --profile (fallow has no native profile concept)',
);
assert.ok(
!combinedSrc.includes('--stdin-files'),
'workflow must not use --stdin-files (removed in fallow 2.x)',
);
});
test('uses --max-crap for threshold control (profile maps to max-crap)', () => {
assert.ok(
fallowStepSrc.includes('--max-crap'),
'workflow must use --max-crap to control threshold (profile mapped to this flag)',
);
});
test('scopes phase via --changed-since (native fallow git-ref scoping)', () => {
assert.ok(
fallowStepSrc.includes('--changed-since'),
'workflow must use --changed-since for phase scoping',
);
});
test('normalizes fallow output via normalizeFallowReportFile before embedding', () => {
// Still in the host (spawn_reviewer step, unconditional) — not gated by
// state:fallow-enabled, since the reviewer prompt assembly always runs.
assert.ok(
workflowSrc.includes('normalizeFallowReportFile'),
'workflow must call normalizeFallowReportFile to normalize before embedding into reviewer prompt',
);
});
test('exit-handling gates on valid JSON (verdict in o), not on exit code', () => {
assert.ok(
fallowStepSrc.includes("'verdict' in o"),
"workflow exit-handling must use 'verdict' in o to decide success (not exit code)",
);
});
});
describe('feat-3210: workflow and config contracts', () => {
test('config schema allows code_quality.fallow.* keys in CJS and runtime manifest', () => {
// CJS config-schema and runtime consume the same manifest source-of-truth.
// Use the CJS runtime Set and the manifest directly (no inline text parsing).
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
const manifestPath = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestKeys = new Set(manifest.validKeys);
for (const key of [
'code_quality.fallow.enabled',
'code_quality.fallow.scope',
'code_quality.fallow.profile',
'code_quality.fallow.mcp',
]) {
assert.ok(VALID_CONFIG_KEYS.has(key), `missing CJS config key: ${key}`);
assert.ok(manifestKeys.has(key), `missing manifest key: ${key} (runtime sources from manifest)`);
}
});
test('config-set accepts code_quality.fallow keys', () => {
const originalTmpDir = process.env.TMPDIR;
// L2: fail loudly if no writable tmp dir is found (was silent skip)
const writableTmp = getWritableTmp(); // N2: use shared helper
assert.ok(writableTmp, 'no writable tmp directory found'); // L2: explicit fail-loud assertion
process.env.TMPDIR = writableTmp;
const tmpDir = createTempProject('gsd-fallow-config-');
try {
const cases = [
['code_quality.fallow.enabled', 'true'],
['code_quality.fallow.scope', 'repo'],
['code_quality.fallow.profile', 'strict'],
['code_quality.fallow.mcp', 'false'],
];
for (const [key, value] of cases) {
const result = runGsdTools(['config-set', key, value], tmpDir);
assert.ok(result.success, `config-set failed for ${key}: ${result.error || result.output}`);
}
} finally {
cleanup(tmpDir);
if (originalTmpDir === undefined) delete process.env.TMPDIR;
else process.env.TMPDIR = originalTmpDir;
}
});
// B4: replaced 5x source-grep tautologies with parse-based structural checks.
// The workflow .md uses XML-like <step> tags as its runtime DSL; we parse the step block
// structurally and assert on structural properties, not on prose strings.
test('code-review workflow structural_pre_pass step is parseable and references FALLOW.json output', () => {
// #2994: structural_pre_pass's gated execution (fallow binary resolve + run
// + FALLOW.json persistence) moved to a marker-gated step file
// (gsd-core/workflows/code-review/steps/structural-pre-pass.md, when=
// "state:fallow-enabled"). The config-gate resolver itself — previously an
// inline `gsd_run query config-get code_quality.fallow.enabled` call inside
// this step's own body (a circular self-disabling gate) — was hoisted into
// `detectFallowConfig` (src/init.cts), consumed by `cmdInitCodeReview`.
const workflow = fs.readFileSync(
path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'),
'utf8',
);
// Parse: the <step name="structural_pre_pass"> block must exist and be closed
const stepMatch = workflow.match(/<step\s+name="structural_pre_pass">([\s\S]*?)<\/step>/);
assert.ok(
stepMatch,
'workflow must contain a parseable <step name="structural_pre_pass">...</step> block',
);
const stepBody = stepMatch[1];
// Structural property: the host stub gates the FALLOW.json-producing
// execution behind state:fallow-enabled and points at the step file.
assert.ok(
stepBody.includes('when="state:fallow-enabled"') &&
stepBody.includes('gsd-core/workflows/code-review/steps/structural-pre-pass.md'),
'structural_pre_pass step must gate its FALLOW.json-producing execution behind state:fallow-enabled',
);
const stepFile = fs.readFileSync(
path.join(ROOT, 'gsd-core', 'workflows', 'code-review', 'steps', 'structural-pre-pass.md'),
'utf8',
);
assert.ok(
stepFile.includes('FALLOW.json'),
'structural-pre-pass.md step file must reference the FALLOW.json output artifact',
);
// Structural property: the config-gate fact is resolved from the real
// code_quality.fallow.enabled config key — checked against the resolver
// it was hoisted into (src/init.cts's detectFallowConfig).
const initSource = fs.readFileSync(path.join(ROOT, 'src', 'init.cts'), 'utf8');
assert.ok(
/detectFallowConfig[\s\S]{0,600}'code_quality'[\s\S]{0,40}'fallow'[\s\S]{0,40}'enabled'/.test(initSource),
'detectFallowConfig (src/init.cts) must gate on code_quality.fallow.enabled',
);
});
// B4: agent output contract — doc-parity check (approved fallback per config-schema-docs-parity
// pattern). We confirm the heading exists in the shipped artifact, not in a live agent response.
// Live agent output is covered by /gsd-code-review e2e runs downstream.
test('reviewer prompt defines ## Structural Findings (fallow) heading and review context echoes it', () => {
const reviewer = fs.readFileSync(path.join(ROOT, 'agents', 'gsd-code-reviewer.md'), 'utf8');
const reviewContext = fs.readFileSync(path.join(ROOT, 'gsd-core', 'contexts', 'review.md'), 'utf8');
// Doc-parity: section heading must exist in the shipped agent file (the heading is a contract,
// not prose — renaming it would break every consumer that parses agent output by section)
assert.ok(
reviewer.includes('## Structural Findings (fallow)'),
'gsd-code-reviewer.md must define ## Structural Findings (fallow) section heading',
);
// Doc-parity: review context that agents receive must reference the same section
assert.ok(
reviewContext.includes('Structural Findings (fallow)'),
'review.md context must reference Structural Findings (fallow) so agents recognize the section',
);
});
});
});
}