Merge origin/main into feat/3597-split-suites-node-matrix

Resolves conflict in .github/workflows/test.yml: keep the 6 new drift-check
steps from main (plan-scan, secrets, schema-detect, decisions,
workstream-name-policy, Shared Module hand-sync) before the split-lane test
runs from this PR. PR's dedicated `coverage` job replaces main's per-matrix
`Run tests with coverage` step.

Other conflicting files (CONTRIBUTING.md, get-shit-done/bin/lib/init.cjs,
package.json) auto-merged cleanly. Changeset files (.changeset/*) brought in
from main as adds.
This commit is contained in:
Tom Boucher
2026-05-16 13:22:07 -04:00
132 changed files with 9379 additions and 1405 deletions

View File

@@ -50,34 +50,35 @@ describe('init commands: agents_installed field (#1371)', () => {
cleanup(tmpDir);
});
// Point the SDK at the repo's agents/ dir (sibling of get-shit-done/) via the
// GSD_AGENTS_DIR override. The SDK side of init resolves agents from
// GSD_AGENTS_DIR or the runtime config dir (~/.claude/agents for Claude); it
// does NOT walk up from cwd like the CJS-era code did. Without this override
// these tests would only pass on a dev machine with ~/.claude/agents/
// populated — which masked the divergence on Linux CI where that path is
// absent. See sdk/src/query/QUERY-HANDLERS.md ("subprocess vs in-process
// path resolution") and sdk/src/query/helpers.ts:resolveAgentsDir.
const REPO_AGENTS_DIR = path.resolve(__dirname, '..', 'agents');
test('init execute-phase includes agents_installed=true when agents exist', () => {
// Create phase dir for init
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
// Create agents dir as sibling of get-shit-done/ (the installed layout)
// gsd-tools.cjs resolves agents from GSD_INSTALL_DIR or __dirname/../../agents
const gsdInstallDir = path.resolve(__dirname, '..', 'get-shit-done', 'bin');
const configDir = path.resolve(gsdInstallDir, '..', '..');
const agentsDir = path.join(configDir, 'agents');
// Agents already exist in the repo root /agents/ dir which is sibling to get-shit-done/
const result = runGsdTools('init execute-phase 1 --raw', tmpDir);
const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { GSD_AGENTS_DIR: REPO_AGENTS_DIR });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(typeof output.agents_installed, 'boolean',
'init execute-phase must include agents_installed field');
// The repo has agents/ dir with all gsd-*.md files, so this should be true
assert.strictEqual(output.agents_installed, true,
'agents_installed should be true when agents directory has gsd-*.md files');
'agents_installed should be true when GSD_AGENTS_DIR has gsd-*.md files');
});
test('init plan-phase includes agents_installed=true when agents exist', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools('init plan-phase 1 --raw', tmpDir);
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { GSD_AGENTS_DIR: REPO_AGENTS_DIR });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);

View File

@@ -87,6 +87,25 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
name.startsWith('gsd-'),
`${cmd}: SKILL.md name should start with gsd-, got "${name}"`
);
// #3583 regression guard: the *body* must not leak retired colon-form
// command references (e.g. /gsd:plan-phase or gsd:review). The converter
// now uses transformContentToHyphen from the shared transformer.
//
// We explicitly scope to the body (after stripping the leading frontmatter
// block) so that descriptions or other frontmatter fields containing example
// gsd: references do not cause spurious failures.
//
// gsd:sdk and gsd:tools are intentionally excluded: they are not slash commands
// (no commands/gsd/sdk.md or tools.md exist), so the transformer correctly leaves
// them alone. They are benign and should not trigger this assertion.
const bodyContent = skillContent.replace(/^---\n[\s\S]*?\n---\n?/, '');
const colonRefs = (bodyContent.match(/\bgsd:[a-z][a-z0-9-]*\b/g) || [])
.filter(r => !/gsd:(sdk|tools)/.test(r));
assert.strictEqual(
colonRefs.length, 0,
`${cmd}: generated SKILL.md body must not contain gsd: command references (found: ${colonRefs.join(', ')})`
);
}
});
@@ -178,4 +197,52 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
assert.ok(!name.includes('_'), `${skillDir}: autocomplete name must not contain underscore, got ${name}`);
}
});
test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const input = 'Run /gsd:plan-phase then gsd:execute-phase. Also see /gsd:review and gsd-sdk query.';
const out = transformContentToHyphen(input, liveCmdNames);
assert.ok(out.includes('/gsd-plan-phase'), 'leading-/ colon form must become hyphen');
assert.ok(out.includes('gsd-execute-phase'), 'bare colon form must become hyphen');
assert.ok(out.includes('/gsd-review'), 'another command reference must be rewritten');
assert.ok(out.includes('gsd-sdk'), 'non-command gsd-sdk must be left untouched');
assert.ok(!out.match(/\bgsd:[a-z]/), 'no colon-form command reference may survive');
});
test('respects word boundary — does not rewrite gsd:plan-phase-extra (partial match guard)', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const out = transformContentToHyphen('gsd:plan-phase-extra and /gsd:execute-phase-extra', liveCmdNames);
assert.strictEqual(out, 'gsd:plan-phase-extra and /gsd:execute-phase-extra',
'word-boundary lookahead must prevent partial matches on the reverse transform');
});
test('respects left word boundary — does not rewrite inside larger tokens (e.g. mygsd:cmd)', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const input = 'See mygsd:plan-phase or prefix-gsd:execute in the docs.';
const out = transformContentToHyphen(input, liveCmdNames);
assert.strictEqual(out, input, 'negative lookbehind must prevent left-side in-word matches');
});
test('leaves already-hyphen-form references untouched (idempotent on output)', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const input = 'Run gsd-plan-phase and /gsd-execute-phase then gsd:review.'; // mixed, only colon should change
const out = transformContentToHyphen(input, liveCmdNames);
assert.ok(out.includes('gsd-plan-phase'), 'pre-existing hyphen stays');
assert.ok(out.includes('/gsd-execute-phase'), 'pre-existing hyphen stays');
assert.ok(out.includes('gsd-review'), 'colon form was normalized');
assert.ok(!out.includes('gsd:review'), 'no colon form remains');
});
});

View File

@@ -0,0 +1,169 @@
'use strict';
/**
* Regression tests for #3406 — stale globally-installed `@gsd-build/sdk@0.1.0`
* shadows the `gsd-sdk` shim that `get-shit-done-cc` installs. The standalone
* 0.1.0 binary only knows `run | auto | init` (no `query` subcommand), so
* every workflow that calls `gsd-sdk query <command>` fails until the user
* runs `npm uninstall -g @gsd-build/sdk`.
*
* Maintainer decision (per triage): option 2 — detect-and-warn during
* install. This test pins the pure detection helper so the install-time
* warning fires on the right input and stays silent otherwise.
*
* Test surface is the exported helper `detectStaleStandaloneSdk(runNpmLs)`.
* `runNpmLs` is an injected executor: in production it spawns
* `npm ls -g @gsd-build/sdk --json --depth=0`; in tests we hand it a stub
* that returns canned stdout / throws, so the test never touches the host
* npm state.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
process.env.GSD_TEST_MODE = '1';
const installer = require('../bin/install.js');
const { detectStaleStandaloneSdk } = installer;
describe('#3406: detectStaleStandaloneSdk', () => {
test('is exported from bin/install.js under GSD_TEST_MODE', () => {
assert.strictEqual(
typeof detectStaleStandaloneSdk,
'function',
'detectStaleStandaloneSdk must be exported for install-time wiring + tests'
);
});
test('returns { stale: false } when npm ls reports the package is not installed', () => {
// `npm ls -g @gsd-build/sdk --json --depth=0` exit code 1 with this
// JSON shape is the standard "not present" signal.
const stub = () => JSON.stringify({
name: 'lib',
dependencies: {},
});
const result = detectStaleStandaloneSdk(stub);
assert.deepStrictEqual(result, { stale: false });
});
test('returns { stale: true, version, path? } when @gsd-build/sdk is present', () => {
const stub = () => JSON.stringify({
name: 'lib',
dependencies: {
'@gsd-build/sdk': {
version: '0.1.0',
resolved: 'file:/Users/REDACTED/.nvm/versions/node/v24.15.0/lib/node_modules/@gsd-build/sdk',
},
},
});
const result = detectStaleStandaloneSdk(stub);
assert.strictEqual(result.stale, true);
assert.strictEqual(result.version, '0.1.0');
});
test('returns { stale: false } when runNpmLs throws (npm missing / EACCES)', () => {
const stub = () => { throw new Error('npm: command not found'); };
const result = detectStaleStandaloneSdk(stub);
assert.deepStrictEqual(result, { stale: false });
});
test('returns { stale: false } when runNpmLs returns malformed JSON', () => {
const stub = () => 'not-json-at-all';
const result = detectStaleStandaloneSdk(stub);
assert.deepStrictEqual(result, { stale: false });
});
test('returns { stale: false } when the JSON has no dependencies field', () => {
const stub = () => JSON.stringify({ name: 'lib' });
const result = detectStaleStandaloneSdk(stub);
assert.deepStrictEqual(result, { stale: false });
});
test('returns { stale: false } when runNpmLs returns null/undefined', () => {
const resultNull = detectStaleStandaloneSdk(() => null);
const resultUndef = detectStaleStandaloneSdk(() => undefined);
assert.deepStrictEqual(resultNull, { stale: false });
assert.deepStrictEqual(resultUndef, { stale: false });
});
test('returns { stale: false } for non-0.1.0 versions (CR #3406)', () => {
// Only 0.1.0 is the known-bad shadow. Any newer or unrelated published
// version is an intentional install (or a future republish) and must
// NOT be flagged. Without this gate, every maintainer with a local-link
// or any future publish would trigger a misleading warning on install.
const stubNewer = () => JSON.stringify({ dependencies: { '@gsd-build/sdk': { version: '1.50.0-canary.0' } } });
const stubFuture = () => JSON.stringify({ dependencies: { '@gsd-build/sdk': { version: '2.0.0' } } });
assert.deepStrictEqual(detectStaleStandaloneSdk(stubNewer), { stale: false });
assert.deepStrictEqual(detectStaleStandaloneSdk(stubFuture), { stale: false });
});
});
describe('#3406: install-time wiring stays silent when no stale package is found', () => {
// We can't easily stub npm inside a spawned install subprocess without
// shelling around it, so the install-side coverage here verifies the
// negative case: when @gsd-build/sdk is NOT installed globally (the npm
// dependency tree on CI is irrelevant — we use a doctored PATH that points
// npm at an empty prefix), the install run prints NO #3406 warning. The
// positive case is exhaustively covered by detectStaleStandaloneSdk above.
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
test('install does not emit the stale-SDK warning on a clean npm prefix', () => {
const installScript = path.resolve(__dirname, '..', 'bin', 'install.js');
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3406-home-'));
const tmpPrefix = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3406-npm-'));
try {
const stdout = execFileSync(
process.execPath,
[installScript, '--claude', '--global', '--yes', '--no-sdk'],
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CLAUDE_CONFIG_DIR: tmpHome,
npm_config_prefix: tmpPrefix,
// Detect-and-warn must execute, just find nothing — so do NOT set
// GSD_SKIP_STALE_SDK_CHECK here.
},
timeout: 60_000,
}
);
assert.ok(
!stdout.includes('@gsd-build/sdk'),
'install output must not mention @gsd-build/sdk when the package is absent'
);
assert.ok(
!stdout.includes('#3406'),
'install output must not reference #3406 when no stale shadow is present'
);
} finally {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
try { fs.rmSync(tmpPrefix, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});
describe('#3406: formatStaleStandaloneSdkWarning', () => {
const { formatStaleStandaloneSdkWarning } = installer;
test('is exported from bin/install.js under GSD_TEST_MODE', () => {
assert.strictEqual(
typeof formatStaleStandaloneSdkWarning,
'function',
'formatStaleStandaloneSdkWarning must be exported for tests'
);
});
test('message names the stale package, the version, and the uninstall command', () => {
const out = formatStaleStandaloneSdkWarning({ stale: true, version: '0.1.0' });
assert.ok(out.includes('@gsd-build/sdk'), 'must name the shadowing package');
assert.ok(out.includes('0.1.0'), 'must show the stale version');
assert.ok(
out.includes('npm uninstall -g @gsd-build/sdk'),
'must include the remediation command verbatim'
);
assert.ok(out.includes('#3406'), 'must reference the issue for traceability');
});
});

View File

@@ -0,0 +1,150 @@
'use strict';
/**
* Regression tests for #3579 — graphify auto-update hook (#3347 / PR #3557)
* was dead-on-arrival in 1.50.0-canary.x because:
*
* Gap 1: scripts/build-hooks.js HOOKS_TO_COPY did not include
* gsd-graphify-update.sh, so it never landed in hooks/dist/ — the
* installer's bin/install.js readdir loop then never copied it to
* ~/.claude/hooks/.
* Gap 2: build-hooks.js (flat allowlist) and bin/install.js (readdir +
* isFile filter) never copied hooks/lib/gsd-graphify-rebuild.sh.
* Without the helper the hook resolves rebuild script → not found →
* exit 0 — feature silently dead.
*
* Beyond these two gaps the issue body lists a Gap 3 (npm tarball missing
* the source files). Inspection of `npm pack --dry-run --json` on origin/main
* shows both files are now present in the tarball, so the tarball-side
* regression is not reproduced; only Gaps 1 & 2 are in scope here.
*
* Test strategy — three layers, each independent:
* 1. build-hooks.js HOOKS_TO_COPY includes every top-level .sh under hooks/
* (allowlist-coverage drift guard). This generalizes beyond graphify so
* the next .sh added cannot drift back into the gap.
* 2. After running scripts/build-hooks.js, hooks/dist/gsd-graphify-update.sh
* and hooks/dist/lib/gsd-graphify-rebuild.sh both exist.
* 3. After installing into a temp config dir, both files land at
* hooks/gsd-graphify-update.sh and hooks/lib/gsd-graphify-rebuild.sh
* and the installer does not emit the "Missing expected hook" warning
* for gsd-graphify-update.sh.
*/
const { test, describe, before, after } = 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 { execFileSync } = require('node:child_process');
const REPO_ROOT = path.resolve(__dirname, '..');
const HOOKS_DIR = path.join(REPO_ROOT, 'hooks');
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
const BUILD_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
const INSTALL_SCRIPT = path.join(REPO_ROOT, 'bin', 'install.js');
// ─── Coverage guard ─────────────────────────────────────────────────────────
describe('#3579 Gap 1: build-hooks.js packages every top-level hooks/*.sh into dist', () => {
// Behavior-based drift guard: rather than parsing the HOOKS_TO_COPY literal
// out of scripts/build-hooks.js as text (a source-grep that breaks under
// harmless refactors and fails to catch any other reason a file might get
// dropped on the floor), we run the actual build and assert the actual
// filesystem outcome: every top-level hooks/*.sh has a corresponding file
// in hooks/dist/. This catches the original gap (missing allowlist entry)
// AND any future regression that silently drops a hook for any other
// reason (e.g. a copy that swallows errors, a syntax-validator bug, etc.).
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
});
test('every top-level hooks/*.sh is emitted to hooks/dist/ by the build', () => {
const topLevelSh = fs
.readdirSync(HOOKS_DIR, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith('.sh'))
.map((e) => e.name);
assert.ok(topLevelSh.length > 0, 'expected at least one top-level hooks/*.sh in source');
const missing = topLevelSh.filter(
(sh) => !fs.existsSync(path.join(DIST_DIR, sh))
);
assert.deepStrictEqual(
missing,
[],
`every top-level hooks/*.sh must be emitted to hooks/dist/ by scripts/build-hooks.js; missing from dist: ${JSON.stringify(missing)}`
);
});
});
// ─── build-hooks emits dist/ files ──────────────────────────────────────────
describe('#3579 Gap 1 + Gap 2: build-hooks.js populates dist with graphify hook + lib helper', () => {
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
});
test('hooks/dist/gsd-graphify-update.sh exists after build', () => {
assert.ok(
fs.existsSync(path.join(DIST_DIR, 'gsd-graphify-update.sh')),
'expected hooks/dist/gsd-graphify-update.sh to exist after build (Gap 1)'
);
});
test('hooks/dist/lib/gsd-graphify-rebuild.sh exists after build', () => {
assert.ok(
fs.existsSync(path.join(DIST_DIR, 'lib', 'gsd-graphify-rebuild.sh')),
'expected hooks/dist/lib/gsd-graphify-rebuild.sh to exist after build (Gap 2)'
);
});
});
// ─── install lands the files at the target ──────────────────────────────────
describe('#3579: installer deploys graphify hook + lib helper to target', () => {
let tmpDir;
let installStdout;
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3579-install-'));
installStdout = execFileSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'],
{
encoding: 'utf-8',
stdio: 'pipe',
env: { ...process.env, CLAUDE_CONFIG_DIR: tmpDir },
}
);
});
after(() => {
if (tmpDir) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
test('hooks/gsd-graphify-update.sh present at install target', () => {
const dest = path.join(tmpDir, 'hooks', 'gsd-graphify-update.sh');
assert.ok(fs.existsSync(dest), `expected ${dest} to exist after install`);
});
test('hooks/lib/gsd-graphify-rebuild.sh present at install target', () => {
const dest = path.join(tmpDir, 'hooks', 'lib', 'gsd-graphify-rebuild.sh');
assert.ok(fs.existsSync(dest), `expected ${dest} to exist after install`);
});
test('installer does not warn about missing gsd-graphify-update.sh', () => {
assert.ok(
!installStdout.includes('Missing expected hook: gsd-graphify-update.sh'),
`installer output must not warn about missing graphify hook; got:\n${installStdout}`
);
assert.ok(
!installStdout.includes(
'Skipped graphify auto-update hook — gsd-graphify-update.sh not found'
),
`installer must not skip graphify hook configuration; got:\n${installStdout}`
);
});
});

View File

@@ -0,0 +1,90 @@
'use strict';
/**
* Regression test for #3588 — production dependency tree must not carry
* high or moderate npm-audit advisories.
*
* Strategy: run `npm audit --omit=dev --json` against both the root
* workspace and the embedded SDK package and assert that the metadata
* vulnerability counts are zero across info/low/moderate/high/critical.
*
* The test is intentionally strict — any advisory of any severity (other
* than 'low' if the maintainer accepts it; that branch is left explicit
* here) blocks CI. If a future advisory lands without an upstream patch,
* either bump the patched transitive (preferred), or annotate the
* acceptance below with a justification AND a link to the upstream tracker.
*
* Skips automatically when `node_modules/` is absent (a fresh checkout
* before `npm install`) so the test does not falsely report on developer
* machines mid-setup.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const SDK = path.join(ROOT, 'sdk');
function auditProductionVulns(cwd) {
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
return null; // signal "skip" to caller
}
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
let out;
try {
out = execFileSync(
npmCmd,
['audit', '--omit=dev', '--json'],
{ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
);
} catch (e) {
// `npm audit` exits non-zero when advisories are present; the JSON is
// still on stdout in that case. Recover and let the assertion classify.
if (e && typeof e.stdout !== 'undefined') {
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
} else {
throw e;
}
}
const parsed = JSON.parse(out);
// `null` is reserved for the "node_modules missing → skip" signal above.
// Any other unexpected JSON shape is a real failure of the audit harness
// (npm changed its output format, audit aborted before metadata, etc.) —
// throw so the test fails loudly instead of skipping silently.
if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) {
return parsed.metadata.vulnerabilities;
}
throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`);
}
describe('#3588: npm audit --omit=dev reports zero advisories', () => {
test('root workspace production tree has no advisories', { timeout: 90_000 }, (t) => {
const vulns = auditProductionVulns(ROOT);
if (vulns === null) {
t.skip('node_modules/ not present — run `npm install` before this test');
return;
}
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
// Low advisories are not explicitly forbidden by the #3588 acceptance
// criterion but the issue listed only high/moderate as actual findings —
// tighten if any future low advisory is introduced.
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
});
test('sdk/ production tree has no advisories', { timeout: 90_000 }, (t) => {
const vulns = auditProductionVulns(SDK);
if (vulns === null) {
t.skip('sdk/node_modules/ not present — run `npm ci` inside sdk/ before this test');
return;
}
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
});
});

View File

@@ -0,0 +1,134 @@
'use strict';
/**
* Regression tests for #3631 — SDK dispatch path in family routers must
* forward the `--raw` flag through to `output()`.
*
* Before the fix, every `*-command-router.cjs` `sdkHandler` called
* `output(result.data)` without the second positional `raw` argument or the
* third positional `rawValue`. With `--raw` set, the SDK path therefore
* emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar
* the CJS path used to print (e.g. `2.1`).
*
* Both tests below exercise the live SDK path:
* 1. `phase next-decimal --raw <base>` must emit the next-decimal token.
* 2. `roadmap get-phase --raw <id>` must emit the phase's roadmap section.
*
* Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not
* substring grep against full JSON.
*/
const { test, describe } = 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 { execFileSync } = require('node:child_process');
const GSD_TOOLS = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
function run(args, cwd) {
try {
return {
ok: true,
stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], {
cwd,
encoding: 'utf-8',
timeout: 15000,
}),
};
} catch (e) {
return {
ok: false,
stdout: (e.stdout && e.stdout.toString()) || '',
stderr: (e.stderr && e.stderr.toString()) || '',
code: e.status,
};
}
}
function makeFixture() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-'));
const planning = path.join(tmp, '.planning');
fs.mkdirSync(path.join(planning, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
[
'# Project Roadmap',
'',
'## v1',
'',
'### Phase 1: First',
'',
'Body of phase 1.',
'',
'### Phase 2: Second',
'',
'Body of phase 2.',
'',
].join('\n')
);
// PROJECT.md anchors the planning root for callers that resolve it.
fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n');
return tmp;
}
describe('bug #3631 — SDK family routers forward --raw to output()', () => {
test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => {
const tmp = makeFixture();
try {
const res = run(['phase', 'next-decimal', '--raw', '1'], tmp);
assert.ok(
res.ok,
`command must succeed; got code=${res.code} stderr=${res.stderr}`
);
const trimmed = res.stdout.trim();
// Scalar form — must be a phase id token like "1.1", not a JSON object.
assert.doesNotMatch(
trimmed,
/^\{/,
`--raw must not emit JSON; got: ${trimmed}`
);
assert.match(
trimmed,
/^0*\d+(?:\.\d+)?$/,
`--raw must emit a scalar phase id; got: ${trimmed}`
);
// SDK and CJS both normalize the base phase before computing the next-
// decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits
// "01.1". Both are valid scalar projections — assert on parity with the
// computed-next semantics rather than the exact padding form.
assert.ok(
trimmed === '1.1' || trimmed === '01.1',
`expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}`
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('roadmap get-phase --raw emits the phase section (not JSON)', () => {
const tmp = makeFixture();
try {
const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp);
assert.ok(
res.ok,
`command must succeed; got code=${res.code} stderr=${res.stderr}`
);
const trimmed = res.stdout.trim();
assert.doesNotMatch(
trimmed,
/^\{/,
`--raw must not emit JSON; got: ${trimmed.slice(0, 80)}`
);
// Section text starts with the heading.
assert.match(
trimmed,
/Phase 2:\s*Second/,
`--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}`
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});

View File

@@ -52,6 +52,7 @@ describe('changeset new: name generator + scaffold writer (#2975)', () => {
type: 'Fixed',
pr: 9999,
body: 'this is a placeholder body that the contributor will replace.',
docsExempt: null,
});
});

View File

@@ -5,7 +5,7 @@ const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { parseFragment, FRAGMENT_ERROR } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'parse.cjs'));
const { parseFragment, extractDocsExempt, FRAGMENT_ERROR, DOCS_EXEMPT_RE } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'parse.cjs'));
describe('changeset parse: fragment file → typed record (#2975)', () => {
test('returns { ok: true, fragment } for a well-formed fragment', () => {
@@ -16,6 +16,7 @@ describe('changeset parse: fragment file → typed record (#2975)', () => {
type: 'Fixed',
pr: 2975,
body: 'fix the thing.',
docsExempt: null,
});
});
@@ -54,3 +55,140 @@ describe('changeset parse: fragment file → typed record (#2975)', () => {
});
}
});
describe('changeset parse: docs-exempt extraction (#3213)', () => {
test('extractDocsExempt returns { docsExempt: null, body } when no marker present', () => {
const out = extractDocsExempt('plain body text');
assert.deepEqual(out, { docsExempt: null, body: 'plain body text' });
});
test('extractDocsExempt captures the reason and strips the marker from body', () => {
const out = extractDocsExempt('feature note.\n\n<!-- docs-exempt: internal-only -->');
assert.equal(out.docsExempt, 'internal-only');
assert.doesNotMatch(out.body, /docs-exempt/);
assert.match(out.body, /feature note\./);
});
test('extractDocsExempt REJECTS bare marker without colon — reason is required (CodeRabbit finding)', () => {
// A bare `<!-- docs-exempt -->` provides no audit trail; intentionally
// not extracted so the lint requires either docs/ updates or a marker
// with a real reason.
const out = extractDocsExempt('body\n<!-- docs-exempt -->');
assert.equal(out.docsExempt, null);
assert.match(out.body, /docs-exempt/); // unchanged — bare marker stays in body
});
test('extractDocsExempt REJECTS marker with empty reason (<!-- docs-exempt: -->)', () => {
const out = extractDocsExempt('body\n<!-- docs-exempt: -->');
assert.equal(out.docsExempt, null);
});
test('extractDocsExempt REJECTS marker with whitespace-only reason', () => {
const out = extractDocsExempt('body\n<!-- docs-exempt: -->');
assert.equal(out.docsExempt, null);
});
test('extractDocsExempt is case-insensitive on the marker token', () => {
const out = extractDocsExempt('body\n<!-- DOCS-EXEMPT: shouty reason -->');
assert.equal(out.docsExempt, 'shouty reason');
});
test('parseFragment surfaces docsExempt on the fragment record', () => {
const src = '---\ntype: Added\npr: 3213\n---\nbootstrap.\n\n<!-- docs-exempt: bootstrap -->\n';
const r = parseFragment(src);
assert.equal(r.ok, true);
assert.equal(r.fragment.docsExempt, 'bootstrap');
// Marker must not appear in the rendered body. CHANGELOG and GitHub
// release-notes serializers append `(#NNNN)` to the body's last line;
// a trailing comment line would attach the suffix to the wrong content.
assert.doesNotMatch(r.fragment.body, /docs-exempt/);
assert.match(r.fragment.body, /bootstrap\./);
});
test('parseFragment fails EMPTY_BODY when the body is only a docs-exempt marker', () => {
const src = '---\ntype: Added\npr: 1\n---\n<!-- docs-exempt: nothing else -->\n';
const r = parseFragment(src);
assert.equal(r.ok, false);
assert.equal(r.reason, FRAGMENT_ERROR.EMPTY_BODY);
});
test('DOCS_EXEMPT_RE is exposed and matches the documented shape (colon + non-empty reason required)', () => {
assert.ok(DOCS_EXEMPT_RE instanceof RegExp);
assert.match('<!-- docs-exempt: x -->', DOCS_EXEMPT_RE);
assert.match('<!-- docs-exempt: bootstrap reason with spaces -->', DOCS_EXEMPT_RE);
assert.doesNotMatch('docs-exempt: not in a comment', DOCS_EXEMPT_RE);
assert.doesNotMatch('<!-- docs-exempt -->', DOCS_EXEMPT_RE); // no colon
assert.doesNotMatch('<!-- docs-exempt: -->', DOCS_EXEMPT_RE); // empty reason
assert.doesNotMatch('<!-- docs-exempt: -->', DOCS_EXEMPT_RE); // whitespace-only reason
});
test('inline mention inside backticks does NOT count as a marker (false-positive guard)', () => {
// Fragment body documents the marker syntax inline as part of release notes.
// Without the line-anchor, the regex would mis-identify this as an actual
// exemption and strip release-note content.
const src =
'---\ntype: Added\npr: 3213\n---\n' +
'New escape hatch: `<!-- docs-exempt: <reason> -->` on its own line at the end of a fragment body exempts that fragment from docs lint.\n';
const r = parseFragment(src);
assert.equal(r.ok, true);
assert.equal(r.fragment.docsExempt, null);
// The literal syntax example must remain in the rendered body — it is
// legitimate release-note content explaining the new feature.
assert.match(r.fragment.body, /docs-exempt/);
});
test('CRLF-authored fragments: marker is stripped cleanly without residual \\r (Codex finding)', () => {
// Codex's exact repro from the second review pass:
// Feature.\r\n\r\n<!-- docs-exempt: x -->\r\n
// Before the fix this parsed to body `Feature.\r\n\r\n\r`, which made
// serializeChangelog emit `- Feature.\r\n\r\n\r (#1)` — the PR suffix
// landed on a blank line instead of attached to the visible bullet.
const src = '---\r\ntype: Added\r\npr: 1\r\n---\r\nFeature.\r\n\r\n<!-- docs-exempt: x -->\r\n';
const r = parseFragment(src);
assert.equal(r.ok, true);
assert.equal(r.fragment.docsExempt, 'x');
assert.doesNotMatch(r.fragment.body, /[\r]/); // no residual CR characters
assert.doesNotMatch(r.fragment.body, /docs-exempt/);
// End-to-end: round-trip through serialize → parse to assert on the
// structured changelog IR, not rendered text (CONTRIBUTING.md:
// "Prohibited: Raw Text Matching on Test Outputs"). The buggy pre-fix
// body shape (`Feature.\r\n\r\n\r`) breaks `parseChangelog`'s bullet
// regex — it returns an empty `bullets: []` — so this round-trip is
// a stronger regression check than a substring match.
const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs'));
const out = serializeChangelog({
releaseHeader: { version: '1.0.0', date: '2026-01-01' },
sections: [{ type: 'Added', bullets: [{ pr: r.fragment.pr, body: r.fragment.body }] }],
priorChangelog: null,
});
const parsed = parseChangelog(out);
assert.equal(parsed.releases.length, 1);
assert.deepEqual(parsed.releases[0].sections, [
{ type: 'Added', bullets: [{ body: 'Feature.', pr: 1 }] },
]);
});
test('CRLF-authored fragment without marker: no stripping needed, body unchanged in semantics', () => {
const src = '---\r\ntype: Fixed\r\npr: 5\r\n---\r\nbug fix.\r\n';
const r = parseFragment(src);
assert.equal(r.ok, true);
assert.equal(r.fragment.docsExempt, null);
assert.match(r.fragment.body, /bug fix\./);
});
test('marker on its own line trailing a fragment body still wins (real-marker positive case)', () => {
const src =
'---\ntype: Added\npr: 3213\n---\n' +
'New escape hatch: `<!-- docs-exempt: <reason> -->` documents the syntax.\n' +
'\n' +
'<!-- docs-exempt: bootstrap of the lint itself -->\n';
const r = parseFragment(src);
assert.equal(r.ok, true);
assert.equal(r.fragment.docsExempt, 'bootstrap of the lint itself');
// The trailing real-marker line is stripped — the "bootstrap" reason
// should not appear anywhere in the rendered body.
assert.doesNotMatch(r.fragment.body, /bootstrap of the lint itself/);
// … but the inline syntax example is preserved.
assert.match(r.fragment.body, /docs-exempt: <reason>/);
});
});

View File

@@ -0,0 +1,93 @@
'use strict';
/**
* Integration test for `get-shit-done/bin/lib/cjs-sdk-bridge.cjs` — locks the
* load-success invariant that Phase 5/6 silently violated before this PR.
*
* Original bug: the bridge used `require('@gsd-build/sdk')` to load the
* runtime-bridge module. That package name is not resolvable from the root
* `node_modules` (the SDK lives at `./sdk/` as a sibling, not a dependency),
* and even if it were, the public entry didn't expose `executeForCjs` or
* `formatStateLoadRawStdout`. `tryLoadSdk()` always returned false,
* `_loadFailed` was cached for the process lifetime, and every CJS router
* silently fell through to the CJS fallback path — making the entire
* CJS→SDK delegation in Phase 5/6 dead code. CI passed because the fallback
* still executed CJS handlers, masking the regression.
*
* This test proves:
* 1. `tryLoadSdk()` returns true on the current checkout.
* 2. `getExecuteForCjs()` returns a real function (not null).
* 3. `getFormatStateLoadRawStdout()` returns a real function (not null).
* 4. Calling `executeForCjs` with a real canonical registry command
* produces a successful SDK result — proving the bridge actually
* dispatches through the runtime bridge rather than failing/falling back.
*
* Requires `sdk/dist/` to exist (i.e. `npm run build:sdk` has run). The
* project's `pretest` hook runs `build:sdk` before tests, so this is met by
* default. If `dist/` is missing, the assertion failures in this file
* surface the cause directly rather than silently masking under fallback.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const BRIDGE_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'cjs-sdk-bridge.cjs');
describe('cjs-sdk-bridge: SDK runtime bridge integration', () => {
test('tryLoadSdk() resolves the bundled SDK on the current checkout', () => {
// Fresh require each run so module-level caches reset.
delete require.cache[require.resolve(BRIDGE_PATH)];
const bridge = require(BRIDGE_PATH);
const loaded = bridge.tryLoadSdk();
assert.strictEqual(
loaded,
true,
'tryLoadSdk() must return true; if false, the bridge can no longer ' +
'locate sdk/dist/runtime-bridge-sync/index.js or its exports — every ' +
'CJS router will fall back to the per-side CJS handler.',
);
});
test('getExecuteForCjs() returns a function after a successful load', () => {
const bridge = require(BRIDGE_PATH);
bridge.tryLoadSdk();
assert.strictEqual(typeof bridge.getExecuteForCjs(), 'function');
});
test('getFormatStateLoadRawStdout() returns a function after a successful load', () => {
const bridge = require(BRIDGE_PATH);
bridge.tryLoadSdk();
assert.strictEqual(typeof bridge.getFormatStateLoadRawStdout(), 'function');
});
test('executeForCjs() actually dispatches a canonical registry command (not a fallback)', () => {
const bridge = require(BRIDGE_PATH);
assert.strictEqual(bridge.tryLoadSdk(), true);
const executeForCjs = bridge.getExecuteForCjs();
// `generate-slug` is a canonical, project-independent command in the SDK
// registry. It does not require a `.planning/` fixture, so its success
// proves the bridge dispatch path works end-to-end without confounding
// it with project-state setup. Same command Phase 5.0's smoke test uses.
const result = executeForCjs({
registryCommand: 'generate-slug',
registryArgs: ['Phase 6 Bridge Wired'],
legacyCommand: 'generate-slug',
legacyArgs: ['Phase 6 Bridge Wired'],
mode: 'json',
projectDir: process.cwd(),
});
assert.strictEqual(
result.ok,
true,
`executeForCjs result.ok must be true; got: ${JSON.stringify(result)}. ` +
'If this fails, the bridge loaded but registry.dispatch did not return ' +
'a typed-ok result for a known-canonical command — the seam is broken.',
);
assert.ok(result.data && typeof result.data === 'object', 'result.data must be an object');
assert.strictEqual(result.data.slug, 'phase-6-bridge-wired');
assert.strictEqual(result.exitCode, 0);
});
});

View File

@@ -89,8 +89,10 @@ describe('convertClaudeCommandToClaudeSkill', () => {
assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)');
});
test('preserves body content unchanged', () => {
const body = '\n<objective>\nDo the thing.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
test('preserves body content while normalizing gsd: command references (#3583)', () => {
// The body transformer now rewrites gsd: references (colon → hyphen) but must
// leave all other custom prose, tags, and structure intact.
const body = '\n<objective>\nSee /gsd:plan-phase and gsd:review for details.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
const input = [
'---',
'name: gsd:test',
@@ -100,10 +102,17 @@ describe('convertClaudeCommandToClaudeSkill', () => {
].join('');
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test');
// Custom structure preserved
assert.ok(result.includes('<objective>'), 'objective tag preserved');
assert.ok(result.includes('Do the thing.'), 'body text preserved');
assert.ok(result.includes('See /gsd-plan-phase'), 'rewritten command reference visible');
assert.ok(result.includes('<process>'), 'process tag preserved');
assert.ok(result.includes('Step 1.'), 'step text preserved');
// #3583: gsd: references in body are normalized to hyphen form
assert.ok(result.includes('/gsd-plan-phase'), 'colon command ref rewritten to hyphen');
assert.ok(result.includes('gsd-review'), 'bare colon ref rewritten to hyphen');
assert.ok(!result.includes('gsd:plan-phase'), 'no colon form should survive in body');
assert.ok(!result.includes('gsd:review'), 'no colon form should survive in body');
});
test('preserves agent field', () => {

View File

@@ -0,0 +1,217 @@
'use strict';
/**
* Parity test: decisions.generated.cjs vs sdk/src/query/decisions.ts
*
* Verifies that the generated CJS artifact matches the SDK source-of-truth
* for all supported ID formats (numeric and alphanumeric) and edge cases.
*
* Covers: Phase 6 (#3575) MIGRATE_ME resolution for decisions.cjs.
*/
const assert = require('assert');
const { describe, test } = require('node:test');
const { parseDecisions } = require('../get-shit-done/bin/lib/decisions.cjs');
// ─── Core parity: numeric IDs (legacy format) ────────────────────────────────
describe('decisions-generator parity — numeric IDs (legacy)', () => {
test('extracts D-NN entries with {id, text}', () => {
const md = `
<decisions>
## Implementation Decisions
### Auth
- **D-01:** Use OAuth 2.0 with PKCE
- **D-02:** Session storage in Redis
### Storage
- **D-03:** Postgres 15 with pgvector
</decisions>
`;
const ds = parseDecisions(md);
assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02', 'D-03']);
assert.strictEqual(ds[0].text, 'Use OAuth 2.0 with PKCE');
});
test('returns [] when no <decisions> block is present', () => {
assert.deepStrictEqual(parseDecisions('# Just a header\nno decisions here'), []);
});
test('returns [] for empty / null / undefined input', () => {
assert.deepStrictEqual(parseDecisions(''), []);
assert.deepStrictEqual(parseDecisions(null), []);
assert.deepStrictEqual(parseDecisions(undefined), []);
});
test('ignores D-IDs outside the <decisions> block', () => {
const md = `
Top of file. - **D-99:** Not a real decision (outside block).
<decisions>
- **D-01:** Real decision
</decisions>
After the block. - **D-77:** Also not real.
`;
const ds = parseDecisions(md);
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
});
});
// ─── Phase 6 extension: alphanumeric IDs ─────────────────────────────────────
describe('decisions-generator parity — alphanumeric IDs (Phase 6 extension)', () => {
test('accepts alphanumeric IDs: D-INFRA-01', () => {
const md = `
<decisions>
### Infrastructure
- **D-INFRA-01:** Use Kubernetes for orchestration
</decisions>
`;
const ds = parseDecisions(md);
assert.strictEqual(ds.length, 1);
assert.strictEqual(ds[0].id, 'D-INFRA-01');
assert.strictEqual(ds[0].text, 'Use Kubernetes for orchestration');
});
test('accepts alphanumeric IDs: D-42 (single numeric)', () => {
const md = `
<decisions>
### Architecture
- **D-42:** Use microservices
</decisions>
`;
const ds = parseDecisions(md);
assert.strictEqual(ds[0].id, 'D-42');
});
test('accepts mixed numeric and alphanumeric IDs in same block', () => {
const md = `
<decisions>
### Planning
- **D-01:** First numeric decision
- **D-FOO_BAR:** Alphanumeric with underscore
- **D-ARCH-123:** Mixed alphanumeric with hyphen
</decisions>
`;
const ds = parseDecisions(md);
const ids = ds.map(d => d.id);
assert.ok(ids.includes('D-01'), 'should have D-01');
assert.ok(ids.includes('D-FOO_BAR'), 'should have D-FOO_BAR');
assert.ok(ids.includes('D-ARCH-123'), 'should have D-ARCH-123');
});
test('CJS callers can use {id, text} shape — extra fields present but safe to ignore', () => {
const md = `
<decisions>
### Category
- **D-INFRA-01:** Database selection
</decisions>
`;
const ds = parseDecisions(md);
const d = ds[0];
// Verify {id, text} is present as CJS callers expect
assert.strictEqual(typeof d.id, 'string');
assert.strictEqual(typeof d.text, 'string');
// Extra SDK fields are present but can be ignored
assert.ok('category' in d, 'category field present');
assert.ok('tags' in d, 'tags field present');
assert.ok('trackable' in d, 'trackable field present');
});
});
// ─── Richer schema fields (SDK extension) ────────────────────────────────────
describe('decisions-generator parity — richer schema', () => {
test('marks decisions under "Claude\'s Discretion" as non-trackable', () => {
const md = `
<decisions>
### Claude's Discretion
- **D-50:** Internal naming is flexible
</decisions>
`;
const ds = parseDecisions(md);
assert.strictEqual(ds[0].trackable, false);
});
test('marks [informational] tagged decisions as non-trackable', () => {
const md = `
<decisions>
### Info
- **D-03 [informational]:** Background context only
</decisions>
`;
const ds = parseDecisions(md);
assert.strictEqual(ds[0].trackable, false);
assert.ok(ds[0].tags.includes('informational'));
});
test('marks [folded] tagged decisions as non-trackable', () => {
const md = `
<decisions>
### Deferred
- **D-05 [folded]:** Will handle later
</decisions>
`;
const ds = parseDecisions(md);
assert.strictEqual(ds[0].trackable, false);
});
test('extracts category from ### heading', () => {
const md = `
<decisions>
### Storage Backend
- **D-01:** Use PostgreSQL
</decisions>
`;
const ds = parseDecisions(md);
assert.strictEqual(ds[0].category, 'Storage Backend');
});
test('parses ALL <decisions> blocks (not just first)', () => {
const md = `
<decisions>
### One
- **D-01:** First batch
</decisions>
Some prose.
<decisions>
### Two
- **D-02:** Second batch
</decisions>
`;
const ids = parseDecisions(md).map(d => d.id);
assert.ok(ids.includes('D-01'));
assert.ok(ids.includes('D-02'));
});
test('strips fenced code blocks before parsing', () => {
const md = `
\`\`\`
<decisions>
### Fake
- **D-99:** Should not be parsed
</decisions>
\`\`\`
<decisions>
### Real
- **D-01:** Real decision
</decisions>
`;
const ds = parseDecisions(md);
const ids = ds.map(d => d.id);
assert.ok(ids.includes('D-01'));
assert.ok(!ids.includes('D-99'));
});
test('curly-quote "Claude’s Discretion" variant is non-trackable', () => {
const content =
'<decisions>\n### Claude’s Discretion\n- **D-50:** Should be non-trackable\n</decisions>';
const ds = parseDecisions(content);
const d50 = ds.find(d => d.id === 'D-50');
assert.ok(d50, 'D-50 should be found');
assert.strictEqual(d50.trackable, false);
});
});

View File

@@ -139,6 +139,14 @@ const INTERNAL_COMPONENT_SLUGS = new Set([
// a belt-and-suspenders guard against the pattern returning in other locale docs.
'alternative-1',
'alternative-2',
// gsd-sync-skills — installed Claude skill directory name (also a workflow
// under get-shit-done/workflows/sync-skills.md), but NOT a registered
// slash command (no commands/gsd/sync-skills.md). Docs reference it as a
// filesystem path component, e.g. "~/.agents/skills/gsd-sync-skills/" in
// docs/discussions/grok-build-support-2026-05.md. The regex captures
// "/gsd-sync-skills" from the path. Invoked via Skill(skill="gsd-sync-skills").
'sync-skills',
]);
/**

View File

@@ -0,0 +1,374 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = 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 {
evaluateLint,
readFragmentsFromDisk,
LINT_REASON,
OPT_OUT_LABEL,
TRIGGERING_TYPES,
isFragmentPath,
isDocsFile,
isExemptFragment,
} = require(path.join(__dirname, '..', 'scripts', 'lint-docs-required.cjs'));
// evaluateLint is pure over the resolved inputs (changedFiles, fragments,
// labels, malformed). Tests assert on the structured verdict:
// { ok, reason: LINT_REASON.X, triggering: string[], malformed? }.
describe('docs-required lint: pure verdict (#3213)', () => {
test('LINT_REASON enum exposes the documented codes', () => {
assert.deepEqual(
Object.keys(LINT_REASON).sort(),
[
'FAIL_DOCS_MISSING',
'FAIL_MALFORMED_FRAGMENT',
'OK_DOCS_UPDATED',
'OK_FRAGMENTS_EXEMPT',
'OK_NO_TRIGGERING_FRAGMENTS',
'OK_OPT_OUT_LABEL',
].sort(),
);
});
test('TRIGGERING_TYPES covers the four user-facing non-fix types', () => {
assert.deepEqual(
[...TRIGGERING_TYPES].sort(),
['Added', 'Changed', 'Deprecated', 'Removed'].sort(),
);
});
test('OPT_OUT_LABEL is no-docs (matches CONTRIBUTING)', () => {
assert.equal(OPT_OUT_LABEL, 'no-docs');
});
test('OK_NO_TRIGGERING_FRAGMENTS when no fragments touched at all', () => {
const verdict = evaluateLint({
changedFiles: ['bin/install.js'],
fragments: [],
labels: [],
});
assert.equal(verdict.ok, true);
assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS);
assert.deepEqual(verdict.triggering, []);
});
test('OK_NO_TRIGGERING_FRAGMENTS for Fixed-only fragments (bug-class)', () => {
const verdict = evaluateLint({
changedFiles: ['bin/install.js', '.changeset/silly-bears-dance.md'],
fragments: [
{ path: '.changeset/silly-bears-dance.md', type: 'Fixed', body: 'fix typo', docsExempt: null },
],
labels: [],
});
assert.deepEqual(verdict, {
ok: true,
reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS,
triggering: [],
});
});
test('OK_NO_TRIGGERING_FRAGMENTS for Security-only fragments', () => {
const verdict = evaluateLint({
changedFiles: [],
fragments: [{ path: '.changeset/a.md', type: 'Security', body: 'cve', docsExempt: null }],
labels: [],
});
assert.equal(verdict.ok, true);
assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS);
});
test('OK_DOCS_UPDATED when Added fragment ships alongside a docs/ change', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'docs/COMMANDS.md'],
fragments: [{ path: '.changeset/a.md', type: 'Added', body: 'new cmd', docsExempt: null }],
labels: [],
});
assert.equal(verdict.ok, true);
assert.equal(verdict.reason, LINT_REASON.OK_DOCS_UPDATED);
assert.deepEqual(verdict.triggering, ['.changeset/a.md']);
});
test('OK_DOCS_UPDATED for nested docs/ paths (docs/adr/, docs/agents/)', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'docs/adr/0099-new.md'],
fragments: [{ path: '.changeset/a.md', type: 'Changed', body: '...', docsExempt: null }],
labels: [],
});
assert.equal(verdict.reason, LINT_REASON.OK_DOCS_UPDATED);
});
for (const type of ['Added', 'Changed', 'Deprecated', 'Removed']) {
test(`FAIL_DOCS_MISSING when ${type} fragment has no docs/ change and no escape hatch`, () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'bin/install.js'],
fragments: [{ path: '.changeset/a.md', type, body: '...', docsExempt: null }],
labels: [],
});
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
assert.deepEqual(verdict.triggering, ['.changeset/a.md']);
});
}
test('OK_OPT_OUT_LABEL when no-docs label present overrides triggering fragments', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'bin/install.js'],
fragments: [{ path: '.changeset/a.md', type: 'Added', body: '...', docsExempt: null }],
labels: ['no-docs'],
});
assert.equal(verdict.ok, true);
assert.equal(verdict.reason, LINT_REASON.OK_OPT_OUT_LABEL);
});
test('per-fragment docsExempt reason exempts that fragment', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'bin/install.js'],
fragments: [
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: 'internal-only' },
],
labels: [],
});
assert.equal(verdict.ok, true);
assert.equal(verdict.reason, LINT_REASON.OK_FRAGMENTS_EXEMPT);
assert.deepEqual(verdict.triggering, ['.changeset/a.md']);
});
test('docsExempt empty string does NOT exempt — defense-in-depth (CodeRabbit finding)', () => {
// parse.cjs no longer produces empty-string docsExempt (the marker regex
// requires a non-empty reason). evaluateLint defends against any caller
// that constructs a fragment with `docsExempt: ''` directly — empty or
// whitespace-only reasons are not a valid audit trail.
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'bin/install.js'],
fragments: [
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: '' },
],
labels: [],
});
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
});
test('docsExempt whitespace-only does NOT exempt — defense-in-depth', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', 'bin/install.js'],
fragments: [
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: ' \t' },
],
labels: [],
});
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
});
test('partial exemption fails — one un-marked triggering fragment is enough to require docs', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', '.changeset/b.md', 'bin/install.js'],
fragments: [
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: 'x' },
{ path: '.changeset/b.md', type: 'Changed', body: 'no marker here', docsExempt: null },
],
labels: [],
});
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
assert.deepEqual(verdict.triggering.sort(), ['.changeset/a.md', '.changeset/b.md']);
});
test('mixed Fixed + Added with no docs still fails — Added triggers', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/a.md', '.changeset/b.md'],
fragments: [
{ path: '.changeset/a.md', type: 'Fixed', body: '...', docsExempt: null },
{ path: '.changeset/b.md', type: 'Added', body: '...', docsExempt: null },
],
labels: [],
});
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
assert.deepEqual(verdict.triggering, ['.changeset/b.md']);
});
});
describe('docs-required lint: malformed fragments fail closed (#3213, Codex finding)', () => {
test('FAIL_MALFORMED_FRAGMENT when a touched fragment failed to parse', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/bad.md'],
fragments: [],
labels: [],
malformed: [{ path: '.changeset/bad.md', reason: 'missing_frontmatter' }],
});
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
assert.deepEqual(verdict.malformed, [{ path: '.changeset/bad.md', reason: 'missing_frontmatter' }]);
});
test('FAIL_MALFORMED_FRAGMENT outranks OK_DOCS_UPDATED — malformed must be fixed first', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/bad.md', '.changeset/ok.md', 'docs/USER-GUIDE.md'],
fragments: [{ path: '.changeset/ok.md', type: 'Added', body: 'fine', docsExempt: null }],
labels: ['no-docs'],
malformed: [{ path: '.changeset/bad.md', reason: 'invalid_type', detail: 'Bogus' }],
});
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
});
test('no-docs label cannot bypass FAIL_MALFORMED_FRAGMENT', () => {
const verdict = evaluateLint({
changedFiles: ['.changeset/bad.md'],
fragments: [],
labels: ['no-docs'],
malformed: [{ path: '.changeset/bad.md', reason: 'missing_pr' }],
});
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
});
test('malformed defaults to [] when omitted — back-compat with simple test inputs', () => {
const verdict = evaluateLint({
changedFiles: [],
fragments: [],
labels: [],
});
assert.equal(verdict.ok, true);
assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS);
});
});
describe('docs-required lint: helpers', () => {
test('isFragmentPath accepts .changeset/<slug>.md, rejects README', () => {
assert.equal(isFragmentPath('.changeset/foo.md'), true);
assert.equal(isFragmentPath('.changeset/silly-bears-dance.md'), true);
assert.equal(isFragmentPath('.changeset/README.md'), false);
assert.equal(isFragmentPath('.changeset/nested/foo.md'), false);
assert.equal(isFragmentPath('docs/COMMANDS.md'), false);
assert.equal(isFragmentPath('bin/install.js'), false);
});
test('isDocsFile matches docs/ prefix only', () => {
assert.equal(isDocsFile('docs/COMMANDS.md'), true);
assert.equal(isDocsFile('docs/adr/0001-foo.md'), true);
assert.equal(isDocsFile('docs/agents/triage-labels.md'), true);
assert.equal(isDocsFile('docs'), false); // exact 'docs' without slash is not a file under docs/
assert.equal(isDocsFile('CONTRIBUTING.md'), false);
assert.equal(isDocsFile('README.md'), false);
});
test('isExemptFragment checks docsExempt is a non-empty string, not body content', () => {
assert.equal(isExemptFragment({ docsExempt: 'reason' }), true);
assert.equal(isExemptFragment({ docsExempt: 'a' }), true);
// Empty/whitespace-only reason → no audit trail → not exempt.
assert.equal(isExemptFragment({ docsExempt: '' }), false);
assert.equal(isExemptFragment({ docsExempt: ' \t' }), false);
assert.equal(isExemptFragment({ docsExempt: null }), false);
assert.equal(isExemptFragment({ docsExempt: undefined }), false);
assert.equal(isExemptFragment({}), false);
// Body content is irrelevant — parse.cjs extracts the marker into docsExempt.
assert.equal(
isExemptFragment({ body: '<!-- docs-exempt: x -->', docsExempt: null }),
false,
);
});
});
describe('docs-required lint: readFragmentsFromDisk', () => {
function withTempRepo(fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-docs-lint-'));
try {
fs.mkdirSync(path.join(tmp, '.changeset'), { recursive: true });
fn(tmp);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
test('returns { fragments, malformed } shape', () => {
withTempRepo((tmp) => {
const out = readFragmentsFromDisk([], tmp);
assert.ok('fragments' in out, 'has fragments');
assert.ok('malformed' in out, 'has malformed');
assert.deepEqual(out.fragments, []);
assert.deepEqual(out.malformed, []);
});
});
test('parses valid fragments and skips non-fragment paths', () => {
withTempRepo((tmp) => {
fs.writeFileSync(
path.join(tmp, '.changeset', 'a.md'),
'---\ntype: Added\npr: 1\n---\nnew feature\n',
);
fs.writeFileSync(
path.join(tmp, '.changeset', 'b.md'),
'---\ntype: Fixed\npr: 2\n---\nbug fix\n',
);
const { fragments, malformed } = readFragmentsFromDisk(
['.changeset/a.md', '.changeset/b.md', 'bin/x.js'],
tmp,
);
assert.equal(fragments.length, 2);
assert.equal(fragments[0].path, '.changeset/a.md');
assert.equal(fragments[0].type, 'Added');
assert.equal(fragments[0].docsExempt, null);
assert.equal(fragments[1].type, 'Fixed');
assert.deepEqual(malformed, []);
});
});
test('skips deleted fragments (path in diff but file gone)', () => {
withTempRepo((tmp) => {
const { fragments, malformed } = readFragmentsFromDisk(['.changeset/deleted.md'], tmp);
assert.deepEqual(fragments, []);
assert.deepEqual(malformed, []);
});
});
test('routes malformed fragments to the malformed list with typed reason', () => {
withTempRepo((tmp) => {
fs.writeFileSync(path.join(tmp, '.changeset', 'bad.md'), 'no frontmatter here\n');
const { fragments, malformed } = readFragmentsFromDisk(['.changeset/bad.md'], tmp);
assert.deepEqual(fragments, []);
assert.equal(malformed.length, 1);
assert.equal(malformed[0].path, '.changeset/bad.md');
assert.equal(malformed[0].reason, 'missing_frontmatter');
});
});
test('Added fragment with bad pr surfaces as malformed (Codex finding regression test)', () => {
withTempRepo((tmp) => {
fs.writeFileSync(
path.join(tmp, '.changeset', 'a.md'),
'---\ntype: Added\n---\nbody but no pr field\n',
);
const { fragments, malformed } = readFragmentsFromDisk(['.changeset/a.md'], tmp);
assert.deepEqual(fragments, []);
assert.equal(malformed.length, 1);
assert.equal(malformed[0].reason, 'missing_pr');
// End-to-end: feed straight into evaluateLint and confirm fail-closed.
const verdict = evaluateLint({ changedFiles: ['.changeset/a.md'], fragments, malformed, labels: [] });
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
});
});
test('extracts docs-exempt marker into typed field and strips it from body', () => {
withTempRepo((tmp) => {
fs.writeFileSync(
path.join(tmp, '.changeset', 'a.md'),
'---\ntype: Added\npr: 3\n---\nnew thing\n\n<!-- docs-exempt: internal-only -->\n',
);
const { fragments } = readFragmentsFromDisk(['.changeset/a.md'], tmp);
assert.equal(fragments.length, 1);
assert.equal(fragments[0].docsExempt, 'internal-only');
// The marker no longer appears in the rendered body — renderers append
// `(#NNNN)` to body's last line, so the marker would otherwise leak into
// CHANGELOG.md / GitHub release notes.
assert.doesNotMatch(fragments[0].body, /docs-exempt/);
});
});
});

View File

@@ -0,0 +1,369 @@
'use strict';
/**
* Tests for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575).
*
* Three cases:
* 1. No new drift pair: lint exits 0 on the current repo tree (all cooperating
* siblings on the allowlist; migrateMeBacklog pairs do not fail).
* 2. Intentional new drift: synthesize a fixture tree with an unlisted
* foo-test.cjs / foo-test.ts pair, assert exit 1 + typed error JSON.
* 3. Allowlist entry honored: same pair as case 2, but with a cooperatingSiblings
* allowlist entry present, assert exit 0.
*
* Assertions use the lint's --json mode: the production code emits a typed IR
* (ok / reason / errors / warnings / counts), and tests parse and assert on
* structured fields rather than substring-matching stderr/stdout (per
* CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs").
*/
const { test, describe } = 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 { spawnSync } = require('node:child_process');
const LINT_SCRIPT = path.join(__dirname, '..', 'scripts', 'lint-shared-module-handsync.cjs');
const ALLOWLIST_PATH = path.join(__dirname, '..', 'scripts', 'shared-module-handsync-allowlist.json');
const REPO_ROOT = path.join(__dirname, '..');
// ---------------------------------------------------------------------------
// Helper: run the lint script in --json mode and parse the result.
// Returns { status, payload } where payload is the parsed JSON IR (or null
// if the lint emitted no JSON, which would be a test-infrastructure bug).
// ---------------------------------------------------------------------------
function runLintJson(extraArgs = []) {
const result = spawnSync(process.execPath, [LINT_SCRIPT, '--json', ...extraArgs], {
encoding: 'utf8',
cwd: REPO_ROOT,
});
let payload = null;
try {
payload = JSON.parse(result.stdout.trim());
} catch {
// Leave payload as null; tests assert on payload presence.
}
return { status: result.status, payload };
}
// ---------------------------------------------------------------------------
// Helper: create an isolated fixture tree for testing
//
// Layout:
// <tmpDir>/
// get-shit-done/bin/lib/<cjsName>.cjs
// sdk/src/query/<tsName>.ts (if tsInQuery === true)
// sdk/src/<tsName>.ts (if tsInQuery === false)
// scripts/shared-module-handsync-allowlist.json (custom allowlist)
// ---------------------------------------------------------------------------
function createFixture({ cjsName, tsName, tsInQuery = true, allowlistExtra = {} }) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-handsync-'));
const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib');
fs.mkdirSync(cjsDir, { recursive: true });
const tsDir = tsInQuery
? path.join(tmpDir, 'sdk', 'src', 'query')
: path.join(tmpDir, 'sdk', 'src');
fs.mkdirSync(tsDir, { recursive: true });
const scriptsDir = path.join(tmpDir, 'scripts');
fs.mkdirSync(scriptsDir, { recursive: true });
fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n// fixture cjs\n`);
fs.writeFileSync(path.join(tsDir, `${tsName}.ts`), `// fixture ts\nexport {};\n`);
const realAllowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
const fixtureAllowlist = {
cooperatingSiblings: [
...(realAllowlist.cooperatingSiblings || []),
...(allowlistExtra.cooperatingSiblings || []),
],
migrateMeBacklog: [
...(realAllowlist.migrateMeBacklog || []),
...(allowlistExtra.migrateMeBacklog || []),
],
};
fs.writeFileSync(
path.join(scriptsDir, 'shared-module-handsync-allowlist.json'),
JSON.stringify(fixtureAllowlist, null, 2)
);
return tmpDir;
}
function cleanupFixture(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// Case 1: No new drift pair — exits 0 on current repo tree
// ---------------------------------------------------------------------------
describe('lint-shared-module-handsync: current repo tree', () => {
test('exits 0 with the real allowlist and current repo tree', () => {
const { status, payload } = runLintJson();
assert.strictEqual(status, 0);
assert.ok(payload, 'expected JSON payload on stdout');
assert.strictEqual(payload.ok, true);
});
test('reports cooperating sibling count and zero unauthorized pairs', () => {
const { payload } = runLintJson();
assert.ok(payload);
assert.strictEqual(typeof payload.cooperatingCount, 'number');
assert.ok(payload.cooperatingCount > 0, 'expected at least one cooperating sibling');
// No errors field on success — only warnings (backlog) may be present
assert.strictEqual(payload.ok, true);
});
test('script has no syntax errors', () => {
const result = spawnSync(process.execPath, ['--check', LINT_SCRIPT], { encoding: 'utf8' });
assert.strictEqual(result.status, 0);
});
});
// ---------------------------------------------------------------------------
// Case 2: Intentional new drift — exits 1 with informative typed error
// ---------------------------------------------------------------------------
describe('lint-shared-module-handsync: intentional new drift pair', () => {
test('exits 1 when an unlisted cjs/ts pair exists', () => {
const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true });
try {
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(status, 1);
assert.ok(payload);
assert.strictEqual(payload.ok, false);
assert.strictEqual(payload.reason, 'unauthorized_pairs');
} finally {
cleanupFixture(tmpDir);
}
});
test('typed error payload names the unauthorized pair', () => {
const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true });
try {
const { payload } = runLintJson(['--root', tmpDir]);
assert.ok(payload && Array.isArray(payload.errors));
assert.strictEqual(payload.errors.length, 1);
const [entry] = payload.errors;
assert.match(entry.relCjs, /foo-test\.cjs$/);
assert.ok(Array.isArray(entry.tsPaths));
assert.ok(entry.tsPaths.some((p) => /foo-test\.ts$/.test(p)));
} finally {
cleanupFixture(tmpDir);
}
});
test('exits 1 for unlisted pair in sdk/src/<name>.ts (non-query) position', () => {
const tmpDir = createFixture({ cjsName: 'bar-test', tsName: 'bar-test', tsInQuery: false });
try {
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(status, 1);
assert.ok(payload);
assert.strictEqual(payload.ok, false);
assert.strictEqual(payload.reason, 'unauthorized_pairs');
} finally {
cleanupFixture(tmpDir);
}
});
});
// ---------------------------------------------------------------------------
// Case 3: Allowlist entry honored — exits 0 when pair IS on cooperatingSiblings
// ---------------------------------------------------------------------------
describe('lint-shared-module-handsync: allowlist entry honored', () => {
test('exits 0 when pair is in cooperatingSiblings allowlist', () => {
const cjsName = 'baz-cooperating';
const tsName = 'baz-cooperating';
const tmpDir = createFixture({
cjsName,
tsName,
tsInQuery: true,
allowlistExtra: {
cooperatingSiblings: [
{
cjs: `get-shit-done/bin/lib/${cjsName}.cjs`,
ts: `sdk/src/query/${tsName}.ts`,
classification: 'cooperating-sibling',
justification: 'Test fixture: synthetic cooperating sibling for lint test.',
},
],
},
});
try {
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(status, 0);
assert.ok(payload);
assert.strictEqual(payload.ok, true);
} finally {
cleanupFixture(tmpDir);
}
});
// Regression guard: the lint matches on the (cjs, ts) PAIR, not on the
// cjs path alone. An allowlist entry whose ts points to a different path
// than the actual ts sibling on disk must NOT silently pass the pair.
test('rejects pair when TS path differs from allowlist entry', () => {
const cjsName = 'foo-wrong-ts';
const tsName = 'foo-wrong-ts';
const tmpDir = createFixture({
cjsName,
tsName,
tsInQuery: true, // creates sdk/src/query/foo-wrong-ts.ts on disk
allowlistExtra: {
cooperatingSiblings: [
{
cjs: `get-shit-done/bin/lib/${cjsName}.cjs`,
// Allowlist points at sdk/src/<name>.ts — different location.
// Lint must reject because the on-disk pair is unauthorized.
ts: `sdk/src/${tsName}.ts`,
classification: 'cooperating-sibling',
justification: 'Test: validates pair-aware matching enforces ts path.',
},
],
},
});
try {
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(status, 1, 'must fail when ts path mismatches');
assert.ok(payload);
assert.strictEqual(payload.ok, false);
assert.strictEqual(payload.reason, 'unauthorized_pairs');
} finally {
cleanupFixture(tmpDir);
}
});
test('exits 0 (no error) when pair is in migrateMeBacklog allowlist', () => {
const cjsName = 'qux-backlog';
const tsName = 'qux-backlog';
const tmpDir = createFixture({
cjsName,
tsName,
tsInQuery: true,
allowlistExtra: {
migrateMeBacklog: [
{
cjs: `get-shit-done/bin/lib/${cjsName}.cjs`,
ts: `sdk/src/query/${tsName}.ts`,
classification: 'drift-anti-pattern',
justification: 'Test fixture: synthetic backlog pair for lint test.',
trackedIn: 'test only',
},
],
},
});
try {
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(status, 0);
assert.ok(payload);
assert.strictEqual(payload.ok, true);
// The backlog pair should be reported in warnings (not errors)
assert.ok(Array.isArray(payload.warnings));
assert.ok(
payload.warnings.some((w) => /qux-backlog\.cjs$/.test(w.relCjs)),
'expected qux-backlog in warnings'
);
} finally {
cleanupFixture(tmpDir);
}
});
// Regression guard for #3632: when a cjs has TWO ts siblings sharing the
// same basename (e.g. sdk/src/foo.ts AND sdk/src/query/foo.ts) and only
// ONE pair is allowlisted, the unallowlisted sibling must still be reported.
// Prior bug: .some() at the cjs level returned true on the allowlisted
// pair, short-circuiting and silently dropping the unallowlisted sibling.
test('reports unallowlisted ts sibling when another ts sibling for the same cjs IS allowlisted (#3632)', () => {
const cjsName = 'multi-sibling';
const tsName = 'multi-sibling';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-multi-'));
try {
const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib');
fs.mkdirSync(cjsDir, { recursive: true });
const tsDirRoot = path.join(tmpDir, 'sdk', 'src');
const tsDirQuery = path.join(tmpDir, 'sdk', 'src', 'query');
fs.mkdirSync(tsDirQuery, { recursive: true });
const scriptsDir = path.join(tmpDir, 'scripts');
fs.mkdirSync(scriptsDir, { recursive: true });
// One cjs, two ts siblings on disk (same basename, different paths).
fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n`);
fs.writeFileSync(path.join(tsDirRoot, `${tsName}.ts`), `export {};\n`);
fs.writeFileSync(path.join(tsDirQuery, `${tsName}.ts`), `export {};\n`);
// Allowlist ONLY the sdk/src/<name>.ts pair. The sdk/src/query/<name>.ts
// sibling is intentionally NOT allowlisted and must be reported.
fs.writeFileSync(
path.join(scriptsDir, 'shared-module-handsync-allowlist.json'),
JSON.stringify(
{
cooperatingSiblings: [
{
cjs: `get-shit-done/bin/lib/${cjsName}.cjs`,
ts: `sdk/src/${tsName}.ts`,
classification: 'cooperating-sibling',
justification: 'Test: only the non-query sibling is allowlisted.',
},
],
migrateMeBacklog: [],
},
null,
2
)
);
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(
status,
1,
'must fail: the sdk/src/query/<name>.ts sibling is not allowlisted'
);
assert.ok(payload);
assert.strictEqual(payload.ok, false);
assert.strictEqual(payload.reason, 'unauthorized_pairs');
assert.ok(Array.isArray(payload.errors) && payload.errors.length >= 1);
const reportedTs = payload.errors.flatMap((e) => e.tsPaths);
assert.ok(
reportedTs.some((p) => /sdk\/src\/query\/multi-sibling\.ts$/.test(p)),
`expected query sibling in errors, got: ${JSON.stringify(reportedTs)}`
);
// The allowlisted sibling must NOT appear in errors.
assert.ok(
!reportedTs.some((p) => /^sdk\/src\/multi-sibling\.ts$/.test(p)),
`allowlisted sibling sdk/src/multi-sibling.ts must not be flagged, got: ${JSON.stringify(reportedTs)}`
);
} finally {
cleanupFixture(tmpDir);
}
});
test('generated .cjs files are excluded from pair detection', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-gen-'));
try {
const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib');
fs.mkdirSync(cjsDir, { recursive: true });
const tsDir = path.join(tmpDir, 'sdk', 'src', 'query');
fs.mkdirSync(tsDir, { recursive: true });
const scriptsDir = path.join(tmpDir, 'scripts');
fs.mkdirSync(scriptsDir, { recursive: true });
// A .generated.cjs file + matching TS — should NOT trigger lint error
fs.writeFileSync(path.join(cjsDir, 'my-module.generated.cjs'), `'use strict';\n`);
fs.writeFileSync(path.join(tsDir, 'my-module.ts'), `export {};\n`);
fs.writeFileSync(
path.join(scriptsDir, 'shared-module-handsync-allowlist.json'),
JSON.stringify({ cooperatingSiblings: [], migrateMeBacklog: [] }, null, 2)
);
const { status, payload } = runLintJson(['--root', tmpDir]);
assert.strictEqual(status, 0);
assert.ok(payload);
assert.strictEqual(payload.ok, true);
} finally {
cleanupFixture(tmpDir);
}
});
});

View File

@@ -0,0 +1,507 @@
'use strict';
/**
* Phase 6 (issue #3524 / PR #3577) — CJS↔SDK seam behavioral contract tests.
*
* Issue #3592 explicitly tracks the migration away from text-existence /
* source-grep tests onto behavioral contract tests. This file is the
* behavioral contract surface for everything Phase 6 introduced:
*
* • `get-shit-done/bin/lib/cjs-sdk-bridge.cjs` — load + cache + surface
* • `sdk/src/runtime-bridge-sync/index.ts` — sync dispatch primitive
* (returns `RuntimeBridgeSyncResult`, a discriminated union with a
* fixed `SyncErrorKind` taxonomy)
* • The 7 family routers (`init|phase|phases|roadmap|state|validate|
* verify-command-router.cjs`) + top-level `gsd-tools.cjs` dispatch —
* each must route a canonical registry command through the bridge
* and emit a JSON-shaped result on stdout.
* • Workstream-scoped commands — Phase 6 made these native; the
* bridge must accept a `workstream` field and the CLI must still
* fall back to CJS when `GSD_WORKSTREAM` is set (the gate the
* routers use to defer to per-side CJS handlers).
*
* Test rules in force (from `CONTRIBUTING.md` § Testing Standards and
* issue #3592):
*
* 1. No `readFileSync` of any `.cjs` source file to assert text
* content. Every assertion is on a parsed JSON object, a
* filesystem fact, an exit code, or a frozen enum value.
* 2. No `assert.match`/`.includes` on free-form child-process stdout
* or stderr. Either parse JSON, or assert on a structured field
* via the bridge API directly.
* 3. Frozen enums describe the canonical taxonomies the production
* code MUST emit. Drift between production and test fails the
* object-shape lock test, not a substring lookup.
* 4. Filesystem assertions use `fs.statSync().isFile()` / `.size` —
* never read the file content back as a substring assertion.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const BRIDGE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'cjs-sdk-bridge.cjs');
// ─── Frozen taxonomies ────────────────────────────────────────────────────────
//
// These describe the canonical shapes Phase 6 ships. Tests assert against the
// enum values, not against substring matches. Adding a new error kind or a
// new bridge export requires updating BOTH the production code AND the
// matching frozen set below — that's three coordinated edits, which is the
// drift-prevention property the new contract pattern is meant to provide.
/** SDK runtime-bridge-sync `SyncErrorKind` taxonomy (sdk/src/runtime-bridge-sync/index.ts:62-68). */
const SYNC_ERROR_KIND = Object.freeze({
UNKNOWN_COMMAND: 'unknown_command',
NATIVE_FAILURE: 'native_failure',
NATIVE_TIMEOUT: 'native_timeout',
FALLBACK_FAILURE: 'fallback_failure',
VALIDATION_ERROR: 'validation_error',
INTERNAL_ERROR: 'internal_error',
});
const SYNC_ERROR_KIND_VALUES = Object.freeze(new Set(Object.values(SYNC_ERROR_KIND)));
/** Surface of `cjs-sdk-bridge.cjs`. Adding an export requires updating both. */
const BRIDGE_EXPORTS = Object.freeze([
'tryLoadSdk',
'getExecuteForCjs',
'getFormatStateLoadRawStdout',
'getSdkModule',
]);
/** TransportMode values accepted by executeForCjs. Bridge must support both. */
const TRANSPORT_MODE = Object.freeze({ JSON: 'json', RAW: 'raw' });
// ─── Bridge module helper ─────────────────────────────────────────────────────
//
// Fresh-require the bridge once per describe block so each test sees an
// isolated load state. `delete require.cache[...]` is the canonical
// reset; never patch internals.
function freshBridge() {
delete require.cache[require.resolve(BRIDGE_PATH)];
return require(BRIDGE_PATH);
}
// ─── 1. Bridge module surface contract ─────────────────────────────────────────
describe('phase 6: cjs-sdk-bridge surface', () => {
test('exposes exactly the documented exports — frozen set', () => {
const bridge = freshBridge();
const actual = Object.keys(bridge).sort();
assert.deepStrictEqual(
actual,
[...BRIDGE_EXPORTS].sort(),
'bridge surface drifted from BRIDGE_EXPORTS — update both production code and the frozen set together',
);
});
test('every documented export is a function', () => {
const bridge = freshBridge();
for (const name of BRIDGE_EXPORTS) {
assert.strictEqual(typeof bridge[name], 'function', `${name} must be a function`);
}
});
});
// ─── 2. Bridge load + cache contract ──────────────────────────────────────────
describe('phase 6: cjs-sdk-bridge load lifecycle', () => {
test('tryLoadSdk resolves the bundled SDK on a working checkout', () => {
const bridge = freshBridge();
assert.strictEqual(bridge.tryLoadSdk(), true);
});
test('post-load getters return non-null when tryLoadSdk succeeded', () => {
const bridge = freshBridge();
bridge.tryLoadSdk();
assert.strictEqual(typeof bridge.getExecuteForCjs(), 'function');
assert.strictEqual(typeof bridge.getFormatStateLoadRawStdout(), 'function');
const mod = bridge.getSdkModule();
assert.ok(mod && typeof mod === 'object', 'getSdkModule must return the cached module object');
assert.strictEqual(typeof mod.executeForCjs, 'function');
});
test('repeated tryLoadSdk calls return the cached result (same reference)', () => {
const bridge = freshBridge();
bridge.tryLoadSdk();
const fn1 = bridge.getExecuteForCjs();
bridge.tryLoadSdk();
const fn2 = bridge.getExecuteForCjs();
assert.strictEqual(fn1, fn2, 'getExecuteForCjs must return the same cached function');
});
test('pre-load getters return null', () => {
const bridge = freshBridge();
assert.strictEqual(bridge.getExecuteForCjs(), null);
assert.strictEqual(bridge.getFormatStateLoadRawStdout(), null);
assert.strictEqual(bridge.getSdkModule(), null);
});
});
// ─── 3. executeForCjs discriminated-union result shape ────────────────────────
describe('phase 6: executeForCjs RuntimeBridgeSyncResult shape', () => {
let bridge;
let executeForCjs;
let tmpDir;
beforeEach(() => {
bridge = freshBridge();
bridge.tryLoadSdk();
executeForCjs = bridge.getExecuteForCjs();
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('ok:true result shape — { ok, data, exitCode }', () => {
const result = executeForCjs({
registryCommand: 'generate-slug',
registryArgs: ['Phase 6 Seam Contract'],
legacyCommand: 'generate-slug',
legacyArgs: ['Phase 6 Seam Contract'],
mode: TRANSPORT_MODE.JSON,
projectDir: tmpDir,
});
assert.strictEqual(result.ok, true);
assert.strictEqual(result.exitCode, 0);
assert.ok(result.data && typeof result.data === 'object', 'data must be an object on ok:true');
assert.strictEqual(typeof result.data.slug, 'string');
});
test('ok:false result for unknown command — errorKind ∈ SyncErrorKind, exitCode ≠ 0', () => {
const result = executeForCjs({
registryCommand: 'totally.unknown.command.xyz',
registryArgs: [],
legacyCommand: 'totally.unknown.command.xyz',
legacyArgs: [],
mode: TRANSPORT_MODE.JSON,
projectDir: tmpDir,
});
assert.strictEqual(result.ok, false);
assert.notStrictEqual(result.exitCode, 0);
assert.ok(
SYNC_ERROR_KIND_VALUES.has(result.errorKind),
`errorKind "${result.errorKind}" must be one of ${[...SYNC_ERROR_KIND_VALUES].join(', ')}`,
);
assert.ok(Array.isArray(result.stderrLines), 'stderrLines must be an array on ok:false');
});
test('mode:"json" returns parsed data, never a JSON-encoded string', () => {
// Regression for the Wave-1 bug where routers passed `mode: 'raw'` and the
// bridge pre-rendered to a JSON string that CJS output() then double-
// stringified. result.data MUST be a structured object/array/primitive
// — never a string that itself parses as JSON.
const result = executeForCjs({
registryCommand: 'generate-slug',
registryArgs: ['Mode Json Check'],
legacyCommand: 'generate-slug',
legacyArgs: ['Mode Json Check'],
mode: TRANSPORT_MODE.JSON,
projectDir: tmpDir,
});
assert.strictEqual(result.ok, true);
assert.notStrictEqual(typeof result.data, 'string',
'mode:"json" must hand callers parsed data, not a serialized JSON blob');
});
});
// ─── 4. CLI family-router dispatch contracts ──────────────────────────────────
//
// One representative read-only command per family. Each test:
// 1. Invokes the CLI through `runGsdTools` (real child process).
// 2. Asserts exit success.
// 3. Parses stdout as JSON.
// 4. Asserts on a structured field, not on prose.
//
// This is the byte-for-byte parity contract Phase 6 promised: SDK-routed
// commands emit the same JSON shape as the legacy CJS handlers used to.
describe('phase 6: CLI family-router dispatch emits structured JSON', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Minimal ROADMAP fixture for any family that scans it.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# v1.0 Roadmap',
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'**Requirements**: REQ-01',
'**Plans:** 0 plans',
'',
].join('\n'),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# State',
'',
'**Current Phase:** 01',
'**Status:** In progress',
'**Total Plans in Phase:** 0',
'**Progress:** [░░░░░░░░░░] 0%',
'**Last Activity:** 2026-05-15',
'',
].join('\n'),
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('roadmap.get-phase emits found:true with structured phase fields', () => {
const result = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir);
assert.ok(result.success, `roadmap get-phase failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true);
assert.strictEqual(payload.phase_number, '1');
assert.strictEqual(payload.phase_name, 'Foundation');
});
test('roadmap.analyze emits a milestones array', () => {
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap analyze failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.ok(Array.isArray(payload.phases), 'phases must be an array');
});
test('phase next-decimal emits a structured next/base shape', () => {
const result = runGsdTools(['phase', 'next-decimal', '1'], tmpDir);
assert.ok(result.success, `phase next-decimal failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.base_phase, '01');
assert.strictEqual(typeof payload.next, 'string');
assert.ok(Array.isArray(payload.existing), 'existing must be an array');
});
test('phases list emits a directories array with count', () => {
const result = runGsdTools(['phases', 'list'], tmpDir);
assert.ok(result.success, `phases list failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.ok(Array.isArray(payload.directories), 'directories must be an array');
assert.strictEqual(typeof payload.count, 'number');
});
test('state json emits a frontmatter object with progress', () => {
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `state json failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.gsd_state_version, '1.0');
assert.ok(payload.progress && typeof payload.progress === 'object',
'progress must be a structured object, not a serialized string');
});
test('init plan-phase emits phase_found + model fields', () => {
const result = runGsdTools(['init', 'plan-phase', '1'], tmpDir);
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.phase_found, true);
assert.strictEqual(payload.phase_number, '1');
assert.strictEqual(typeof payload.researcher_model, 'string');
});
test('validate consistency emits valid + warnings array', () => {
const result = runGsdTools(['validate', 'consistency'], tmpDir);
assert.ok(result.success, `validate consistency failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.ok(typeof payload.valid === 'boolean' || Array.isArray(payload.warnings),
'validate consistency must emit either {valid, warnings} shape');
});
test('find-phase for non-existent phase emits found:false (not a process error)', () => {
const result = runGsdTools(['find-phase', '99'], tmpDir);
assert.ok(result.success, `find-phase should not error on missing phase: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, false);
});
});
// ─── 5. mode:"json" prevents double-stringify (Wave 1 bug regression) ─────────
describe('phase 6: mode:"json" never double-stringifies the data', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# v1.0',
'',
'### Phase 1: Setup',
'**Goal:** Initial setup',
'',
].join('\n'),
);
});
afterEach(() => {
cleanup(tmpDir);
});
// The Wave-1 bug shape: stdout looked like JSON of JSON, e.g.
// "\"{\\n \\\"found\\\": true\"".
// After the fix, stdout is a single JSON object that parses to an object —
// never a string that itself parses to an object.
test('roadmap get-phase stdout parses to an object, not a JSON-encoded string', () => {
const result = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir);
assert.ok(result.success, `command failed: ${result.error}`);
const first = JSON.parse(result.output);
assert.strictEqual(
typeof first,
'object',
'CLI stdout for a JSON-mode command must parse directly to an object',
);
assert.notStrictEqual(
typeof first,
'string',
'double-stringify regression: stdout parsed to a string that would itself parse as JSON',
);
});
});
// ─── 6. Workstream-scoped CJS fallback gate ────────────────────────────────────
//
// Phase 6 made workstream-scoped commands native in the SDK transport, BUT the
// CJS routers still force CJS fallback when `GSD_WORKSTREAM` is set in the
// environment, so workstream-aware tests and inspections can target a
// specific workstream's `.planning/` slice without round-tripping through
// the synckit worker. Both modes must work and must produce the same JSON
// shape for the same input fixture.
describe('phase 6: GSD_WORKSTREAM gate routes through CJS fallback consistently', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# v1.0', '', '### Phase 1: Setup', '**Goal:** Setup', ''].join('\n'),
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('roadmap get-phase produces identical structured output with and without GSD_WORKSTREAM unset', () => {
const sdkPath = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir);
assert.ok(sdkPath.success, `SDK dispatch failed: ${sdkPath.error}`);
const sdkPayload = JSON.parse(sdkPath.output);
// When GSD_WORKSTREAM is set, the router falls through to CJS. For the
// primary planning slice (no workstream subdir yet), passing the env var
// should still parse the same ROADMAP.md and emit the same fields.
const cjsPath = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir, { GSD_WORKSTREAM: '' });
assert.ok(cjsPath.success, `CJS fallback dispatch failed: ${cjsPath.error}`);
const cjsPayload = JSON.parse(cjsPath.output);
// Compare structured fields, never the rendered text.
assert.strictEqual(sdkPayload.found, cjsPayload.found);
assert.strictEqual(sdkPayload.phase_number, cjsPayload.phase_number);
assert.strictEqual(sdkPayload.phase_name, cjsPayload.phase_name);
});
});
// ─── 7. Validation-error contract for malformed input ──────────────────────────
//
// When a registry command receives an invalid argument, the bridge must map
// the error to `validation_error` in the SyncErrorKind taxonomy and surface a
// non-zero exit code. This is the "negative path" coverage that #3592
// explicitly calls out as required.
describe('phase 6: validation errors map to SyncErrorKind.validation_error', () => {
let bridge;
let executeForCjs;
let tmpDir;
beforeEach(() => {
bridge = freshBridge();
bridge.tryLoadSdk();
executeForCjs = bridge.getExecuteForCjs();
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('find-phase with empty phase identifier returns ok:false + validation_error', () => {
const result = executeForCjs({
registryCommand: 'find-phase',
registryArgs: [],
legacyCommand: 'find-phase',
legacyArgs: [],
mode: TRANSPORT_MODE.JSON,
projectDir: tmpDir,
});
assert.strictEqual(result.ok, false);
assert.strictEqual(result.errorKind, SYNC_ERROR_KIND.VALIDATION_ERROR,
`validation errors must map to ${SYNC_ERROR_KIND.VALIDATION_ERROR}, got ${result.errorKind}`);
assert.notStrictEqual(result.exitCode, 0, 'validation_error must produce a non-zero exit code');
});
});
// ─── 8. Filesystem-fact write contract ─────────────────────────────────────────
//
// Phase 6 routes phase.add through the SDK. After a successful add, the
// phase directory and ROADMAP entry must be on disk. Test asserts on
// filesystem facts (`existsSync`, `statSync().isDirectory()`, file size > 0)
// — never reads the file content back as a substring assertion.
describe('phase 6: phase.add SDK dispatch writes the expected filesystem facts', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# v1.0 Roadmap',
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'',
'---',
'',
].join('\n'),
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('phase add User Dashboard creates phase 2 directory + appends ROADMAP entry', () => {
const before = fs.statSync(path.join(tmpDir, '.planning', 'ROADMAP.md'));
const result = runGsdTools(['phase', 'add', 'User', 'Dashboard'], tmpDir);
assert.ok(result.success, `phase add failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.phase_number, 2);
assert.strictEqual(payload.slug, 'user-dashboard');
// Filesystem facts: the directory exists and is a directory; the roadmap
// file grew (write happened). We do not read the file back to look for
// substrings — that's the prohibited pattern.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-user-dashboard');
assert.ok(fs.existsSync(phaseDir), 'new phase directory must exist on disk');
assert.ok(fs.statSync(phaseDir).isDirectory(), 'phase path must be a directory');
const after = fs.statSync(path.join(tmpDir, '.planning', 'ROADMAP.md'));
assert.ok(after.size > before.size, 'ROADMAP.md must grow when phase add appends an entry');
});
});

View File

@@ -1,10 +1,25 @@
'use strict';
const { describe, test } = require('node:test');
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { routePhasesCommand } = require('../get-shit-done/bin/lib/phases-command-router.cjs');
// These tests exercise the CJS dispatch path of the router. Since #3577 the
// router prefers the SDK bridge when sdk/dist is present, which would bypass
// the mocked `phase`/`milestone` handlers below. The router gates SDK
// dispatch on `process.env.GSD_WORKSTREAM` being unset, so set it for these
// tests to deterministically take the CJS path that the mocks model.
let _prevWorkstream;
before(() => {
_prevWorkstream = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'test-unit';
});
after(() => {
if (_prevWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = _prevWorkstream;
});
describe('phases-command-router', () => {
test('routes phases list with parsed options', () => {
const calls = [];

View File

@@ -0,0 +1,196 @@
'use strict';
/**
* Parity test — verifies that plan-scan.generated.cjs produces identical
* results to the compiled SDK ESM output for all exported functions.
*
* SDK side: import('../sdk/dist/query/plan-scan.js')
* CJS side: require('../get-shit-done/bin/lib/plan-scan.generated.cjs')
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const path = require('path');
const os = require('os');
const fs = require('fs');
const crypto = require('crypto');
/**
* Build a unique-to-this-run path that is guaranteed not to exist. Hardcoded
* `/tmp/...` paths are a flake source on shared CI runners where the path can
* be left over from a prior run. We synthesize a random suffix under
* `os.tmpdir()` and force-remove the path first.
*/
function uniqueMissingPath(prefix = 'gsd-missing') {
const suffix = `${prefix}-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
const p = path.join(os.tmpdir(), suffix);
// The probability of collision is negligible, but force-clean anyway to make
// the precondition explicit. Errors swallowed (path didn't exist — desired).
try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* noop */ }
return p;
}
const requireFromRoot = createRequire(__filename);
// CJS side — direct require works fine
const cjs = requireFromRoot('../get-shit-done/bin/lib/plan-scan.generated.cjs');
// ── isRootPlanFile ────────────────────────────────────────────────────────
describe('plan-scan-generator parity: isRootPlanFile', async () => {
const sdk = await import('../sdk/dist/query/plan-scan.js');
const fixtures = [
{ label: 'accepts bare PLAN.md', name: 'PLAN.md', expected: true },
{ label: 'accepts canonical -PLAN.md', name: '01-01-PLAN.md', expected: true },
{ label: 'accepts extended PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true },
{ label: 'rejects -PLAN-OUTLINE.md', name: 'something-PLAN-OUTLINE.md', expected: false },
{ label: 'rejects .pre-bounce.md', name: 'PLAN.pre-bounce.md', expected: false },
{ label: 'rejects SUMMARY.md', name: 'SUMMARY.md', expected: false },
{ label: 'rejects unrelated file', name: 'README.md', expected: false },
];
for (const { label, name, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.isRootPlanFile(name);
const cjsResult = cjs.isRootPlanFile(name);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── isNestedPlanFile ──────────────────────────────────────────────────────
describe('plan-scan-generator parity: isNestedPlanFile', async () => {
const sdk = await import('../sdk/dist/query/plan-scan.js');
const fixtures = [
{ label: 'accepts PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true },
{ label: 'accepts 1-PLAN-01-setup.md', name: '1-PLAN-01-setup.md', expected: true },
{ label: 'rejects PLAN-OUTLINE.md', name: 'PLAN-01-OUTLINE.md', expected: false },
{ label: 'rejects .pre-bounce.md', name: 'PLAN-01.pre-bounce.md', expected: false },
{ label: 'rejects bare PLAN.md', name: 'PLAN.md', expected: false },
{ label: 'rejects unrelated file', name: 'SUMMARY-01-setup.md', expected: false },
];
for (const { label, name, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.isNestedPlanFile(name);
const cjsResult = cjs.isNestedPlanFile(name);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── isRootSummaryFile ─────────────────────────────────────────────────────
describe('plan-scan-generator parity: isRootSummaryFile', async () => {
const sdk = await import('../sdk/dist/query/plan-scan.js');
const fixtures = [
{ label: 'accepts bare SUMMARY.md', name: 'SUMMARY.md', expected: true },
{ label: 'accepts 01-01-SUMMARY.md', name: '01-01-SUMMARY.md', expected: true },
{ label: 'rejects PLAN.md', name: 'PLAN.md', expected: false },
{ label: 'rejects unrelated file', name: 'README.md', expected: false },
];
for (const { label, name, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.isRootSummaryFile(name);
const cjsResult = cjs.isRootSummaryFile(name);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── isNestedSummaryFile ───────────────────────────────────────────────────
describe('plan-scan-generator parity: isNestedSummaryFile', async () => {
const sdk = await import('../sdk/dist/query/plan-scan.js');
const fixtures = [
{ label: 'accepts SUMMARY-01-summary.md', name: 'SUMMARY-01-summary.md', expected: true },
{ label: 'accepts 1-SUMMARY-01.md', name: '1-SUMMARY-01.md', expected: true },
{ label: 'rejects bare SUMMARY.md', name: 'SUMMARY.md', expected: false },
{ label: 'rejects PLAN file', name: 'PLAN-01-setup.md', expected: false },
];
for (const { label, name, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.isNestedSummaryFile(name);
const cjsResult = cjs.isNestedSummaryFile(name);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── scanPhasePlans ────────────────────────────────────────────────────────
describe('plan-scan-generator parity: scanPhasePlans (non-existent dir)', async () => {
const sdk = await import('../sdk/dist/query/plan-scan.js');
test('returns zero counts for non-existent directory', () => {
const nonExistent = uniqueMissingPath('gsd-plan-scan-nonexistent');
const sdkResult = sdk.scanPhasePlans(nonExistent);
const cjsResult = cjs.scanPhasePlans(nonExistent);
assert.deepStrictEqual(sdkResult, {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
});
assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: non-existent dir');
});
});
describe('plan-scan-generator parity: scanPhasePlans (flat layout)', async () => {
const sdk = await import('../sdk/dist/query/plan-scan.js');
test('detects flat plan and summary files', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-test-'));
try {
fs.writeFileSync(path.join(tmpDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(tmpDir, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Readme');
const sdkResult = sdk.scanPhasePlans(tmpDir);
const cjsResult = cjs.scanPhasePlans(tmpDir);
assert.strictEqual(sdkResult.planCount, 1, 'SDK: planCount');
assert.strictEqual(sdkResult.summaryCount, 1, 'SDK: summaryCount');
assert.strictEqual(sdkResult.completed, true, 'SDK: completed');
assert.strictEqual(sdkResult.hasNestedPlans, false, 'SDK: hasNestedPlans');
assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: flat layout');
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
});
describe('plan-scan-generator parity: module.exports call style', async () => {
test('default export is callable as function (CJS caller pattern)', () => {
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
// then call it directly: scanPhasePlans(phaseDir)
assert.strictEqual(typeof cjs, 'function', 'default export is a function');
const result = cjs(uniqueMissingPath('gsd-plan-scan-cjs-default'));
assert.deepStrictEqual(result, {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
});
});
});

View File

@@ -1,10 +1,25 @@
'use strict';
const { describe, test } = require('node:test');
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { routeRoadmapCommand } = require('../get-shit-done/bin/lib/roadmap-command-router.cjs');
// These tests exercise the CJS dispatch path of the router. Since #3577 the
// router prefers the SDK bridge when sdk/dist is present, which would bypass
// the mocked `roadmap` handlers below. The router gates SDK dispatch on
// `process.env.GSD_WORKSTREAM` being unset, so set it here to deterministically
// take the CJS path that the mocks model.
let _prevWorkstream;
before(() => {
_prevWorkstream = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'test-unit';
});
after(() => {
if (_prevWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = _prevWorkstream;
});
describe('roadmap-command-router', () => {
test('routes roadmap analyze', () => {
const calls = [];

View File

@@ -0,0 +1,195 @@
'use strict';
/**
* Parity test — verifies that schema-detect.generated.cjs produces identical
* results to the compiled SDK ESM output for all exported functions.
*
* SDK side: import('../sdk/dist/query/schema-detect.js')
* CJS side: require('../get-shit-done/bin/lib/schema-detect.generated.cjs')
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const requireFromRoot = createRequire(__filename);
// CJS side — direct require works fine
const cjs = requireFromRoot('../get-shit-done/bin/lib/schema-detect.generated.cjs');
// ── detectSchemaFiles ─────────────────────────────────────────────────────
describe('schema-detect-generator parity: detectSchemaFiles', async () => {
const sdk = await import('../sdk/dist/query/schema-detect.js');
const fixtures = [
{
label: 'detects prisma schema',
files: ['prisma/schema.prisma'],
expectedDetected: true,
expectedOrms: ['prisma'],
},
{
label: 'detects drizzle schema',
files: ['drizzle/schema.ts'],
expectedDetected: true,
expectedOrms: ['drizzle'],
},
{
label: 'detects supabase migration',
files: ['supabase/migrations/001_init.sql'],
expectedDetected: true,
expectedOrms: ['supabase'],
},
{
label: 'detects payload collection',
files: ['src/collections/Users.ts'],
expectedDetected: true,
expectedOrms: ['payload'],
},
{
label: 'detects typeorm entity',
files: ['src/entities/User.ts'],
expectedDetected: true,
expectedOrms: ['typeorm'],
},
{
label: 'no schema files returns not detected',
files: ['src/components/Button.tsx', 'src/styles/main.css'],
expectedDetected: false,
expectedOrms: [],
},
{
label: 'multiple ORMs detected',
files: ['prisma/schema.prisma', 'drizzle/schema.ts'],
expectedDetected: true,
expectedOrms: ['prisma', 'drizzle'],
},
{
label: 'normalizes Windows backslash paths',
files: ['prisma\\schema.prisma'],
expectedDetected: true,
expectedOrms: ['prisma'],
},
{
label: 'empty file list returns not detected',
files: [],
expectedDetected: false,
expectedOrms: [],
},
];
for (const { label, files, expectedDetected, expectedOrms } of fixtures) {
test(label, () => {
const sdkResult = sdk.detectSchemaFiles(files);
const cjsResult = cjs.detectSchemaFiles(files);
assert.strictEqual(sdkResult.detected, expectedDetected, `SDK detected: ${label}`);
assert.deepStrictEqual(sdkResult.orms.sort(), expectedOrms.sort(), `SDK orms: ${label}`);
assert.strictEqual(cjsResult.detected, expectedDetected, `CJS detected: ${label}`);
assert.deepStrictEqual(cjsResult.orms.sort(), expectedOrms.sort(), `CJS orms: ${label}`);
// SDK and CJS must agree on detected and orms
assert.strictEqual(sdkResult.detected, cjsResult.detected, `SDK/CJS parity detected: ${label}`);
assert.deepStrictEqual(sdkResult.orms.sort(), cjsResult.orms.sort(), `SDK/CJS parity orms: ${label}`);
});
}
});
// ── checkSchemaDrift ──────────────────────────────────────────────────────
describe('schema-detect-generator parity: checkSchemaDrift', async () => {
const sdk = await import('../sdk/dist/query/schema-detect.js');
const fixtures = [
{
label: 'no schema files — no drift',
changedFiles: ['src/components/Button.tsx'],
executionLog: '',
options: {},
expectedDriftDetected: false,
expectedBlocking: false,
},
{
label: 'prisma changed with push evidence — no drift',
changedFiles: ['prisma/schema.prisma'],
executionLog: 'running: npx prisma db push --accept-data-loss',
options: {},
expectedDriftDetected: false,
expectedBlocking: false,
},
{
label: 'prisma changed without push — drift blocking',
changedFiles: ['prisma/schema.prisma'],
executionLog: 'tsc && vitest run',
options: {},
expectedDriftDetected: true,
expectedBlocking: true,
},
{
label: 'drift with skipCheck=true — not blocking',
changedFiles: ['prisma/schema.prisma'],
executionLog: 'tsc && vitest run',
options: { skipCheck: true },
expectedDriftDetected: true,
expectedBlocking: false,
},
];
for (const { label, changedFiles, executionLog, options, expectedDriftDetected, expectedBlocking } of fixtures) {
test(label, () => {
const sdkResult = sdk.checkSchemaDrift(changedFiles, executionLog, options);
const cjsResult = cjs.checkSchemaDrift(changedFiles, executionLog, options);
assert.strictEqual(sdkResult.driftDetected, expectedDriftDetected, `SDK driftDetected: ${label}`);
assert.strictEqual(sdkResult.blocking, expectedBlocking, `SDK blocking: ${label}`);
assert.strictEqual(cjsResult.driftDetected, expectedDriftDetected, `CJS driftDetected: ${label}`);
assert.strictEqual(cjsResult.blocking, expectedBlocking, `CJS blocking: ${label}`);
// Full structural parity between SDK and CJS
assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── detectSchemaOrm (CJS-only compat export) ──────────────────────────────
describe('schema-detect-generator parity: detectSchemaOrm (CJS compat)', () => {
test('returns ORM info for known orm', () => {
const info = cjs.detectSchemaOrm('prisma');
assert.ok(info !== null, 'prisma orm info should not be null');
assert.ok(typeof info.pushCommand === 'string', 'pushCommand should be string');
assert.ok(Array.isArray(info.evidencePatterns), 'evidencePatterns should be array');
});
test('returns null for unknown orm', () => {
const info = cjs.detectSchemaOrm('unknown_orm');
assert.strictEqual(info, null, 'unknown orm should return null');
});
test('returns info for all 5 known ORMs', () => {
const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm'];
for (const orm of orms) {
const info = cjs.detectSchemaOrm(orm);
assert.ok(info !== null, `${orm} info should not be null`);
}
});
});
// ── SCHEMA_PATTERNS and ORM_INFO exports (compat) ────────────────────────
describe('schema-detect-generator: SCHEMA_PATTERNS and ORM_INFO exported', () => {
test('SCHEMA_PATTERNS is an array', () => {
assert.ok(Array.isArray(cjs.SCHEMA_PATTERNS), 'SCHEMA_PATTERNS should be an array');
assert.ok(cjs.SCHEMA_PATTERNS.length > 0, 'SCHEMA_PATTERNS should not be empty');
});
test('ORM_INFO has known orm keys', () => {
const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm'];
for (const orm of orms) {
assert.ok(orm in cjs.ORM_INFO, `ORM_INFO should have key: ${orm}`);
}
});
});

View File

@@ -0,0 +1,136 @@
'use strict';
/**
* Parity test — verifies that secrets.generated.cjs produces identical
* results to the compiled SDK ESM output for all exported functions.
*
* SDK side: import('../sdk/dist/query/secrets.js')
* CJS side: require('../get-shit-done/bin/lib/secrets.generated.cjs')
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const requireFromRoot = createRequire(__filename);
// CJS side — direct require works fine
const cjs = requireFromRoot('../get-shit-done/bin/lib/secrets.generated.cjs');
// ── SECRET_CONFIG_KEYS ────────────────────────────────────────────────────
describe('secrets-generator parity: SECRET_CONFIG_KEYS', async () => {
const sdk = await import('../sdk/dist/query/secrets.js');
test('contains same keys as SDK', () => {
const sdkKeys = [...sdk.SECRET_CONFIG_KEYS].sort();
const cjsKeys = [...cjs.SECRET_CONFIG_KEYS].sort();
assert.deepStrictEqual(cjsKeys, sdkKeys, 'SDK/CJS parity: SECRET_CONFIG_KEYS');
});
test('contains brave_search', () => {
assert.ok(cjs.SECRET_CONFIG_KEYS.has('brave_search'));
assert.ok(sdk.SECRET_CONFIG_KEYS.has('brave_search'));
});
test('contains firecrawl', () => {
assert.ok(cjs.SECRET_CONFIG_KEYS.has('firecrawl'));
assert.ok(sdk.SECRET_CONFIG_KEYS.has('firecrawl'));
});
test('contains exa_search', () => {
assert.ok(cjs.SECRET_CONFIG_KEYS.has('exa_search'));
assert.ok(sdk.SECRET_CONFIG_KEYS.has('exa_search'));
});
});
// ── isSecretKey ────────────────────────────────────────────────────────────
describe('secrets-generator parity: isSecretKey', async () => {
const sdk = await import('../sdk/dist/query/secrets.js');
const fixtures = [
{ label: 'brave_search is secret', key: 'brave_search', expected: true },
{ label: 'firecrawl is secret', key: 'firecrawl', expected: true },
{ label: 'exa_search is secret', key: 'exa_search', expected: true },
{ label: 'non-secret key returns false', key: 'model', expected: false },
{ label: 'empty string returns false', key: '', expected: false },
{ label: 'unrelated string returns false', key: 'api_key', expected: false },
];
for (const { label, key, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.isSecretKey(key);
const cjsResult = cjs.isSecretKey(key);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── maskSecret ────────────────────────────────────────────────────────────
describe('secrets-generator parity: maskSecret', async () => {
const sdk = await import('../sdk/dist/query/secrets.js');
const fixtures = [
{ label: 'null returns (unset)', value: null, expected: '(unset)' },
{ label: 'undefined returns (unset)', value: undefined, expected: '(unset)' },
{ label: 'empty string returns (unset)', value: '', expected: '(unset)' },
{ label: 'short string (< 8) returns ****', value: 'abc', expected: '****' },
{ label: '7-char string returns ****', value: '1234567', expected: '****' },
{ label: '8-char string returns ****<last-4>', value: '12345678', expected: '****5678' },
{ label: 'long string returns ****<last-4>', value: 'sk-ant-abc123def456', expected: '****f456' },
];
for (const { label, value, expected } of fixtures) {
test(label, () => {
const sdkResult = sdk.maskSecret(value);
const cjsResult = cjs.maskSecret(value);
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});
// ── maskIfSecret ──────────────────────────────────────────────────────────
describe('secrets-generator parity: maskIfSecret', async () => {
const sdk = await import('../sdk/dist/query/secrets.js');
const fixtures = [
{
label: 'secret key gets masked',
key: 'brave_search',
value: 'sk-ant-12345678',
expectedType: 'string',
expectedValue: '****5678',
},
{
label: 'non-secret key returns value unchanged',
key: 'model',
value: 'claude-opus-4-5',
expectedType: 'string',
expectedValue: 'claude-opus-4-5',
},
{
label: 'secret key with null value returns (unset)',
key: 'firecrawl',
value: null,
expectedType: 'string',
expectedValue: '(unset)',
},
];
for (const { label, key, value, expectedValue } of fixtures) {
test(label, () => {
const sdkResult = sdk.maskIfSecret(key, value);
const cjsResult = cjs.maskIfSecret(key, value);
assert.strictEqual(sdkResult, expectedValue, `SDK: ${label}`);
assert.strictEqual(cjsResult, expectedValue, `CJS: ${label}`);
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
});
}
});

View File

@@ -0,0 +1,145 @@
'use strict';
/**
* Parity test: workstream-name-policy.generated.cjs vs sdk/src/workstream-name-policy.ts
*
* Verifies that the generated CJS artifact matches the SDK source-of-truth
* for all exports: toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName,
* validateWorkstreamName.
*
* Covers: Phase 6 (#3575) MIGRATE_ME resolution for workstream-name-policy.cjs.
*/
const assert = require('assert');
const { describe, test } = require('node:test');
const {
toWorkstreamSlug,
hasInvalidPathSegment,
isValidActiveWorkstreamName,
validateWorkstreamName,
} = require('../get-shit-done/bin/lib/workstream-name-policy.cjs');
// ─── toWorkstreamSlug ────────────────────────────────────────────────────────
describe('workstream-name-policy — toWorkstreamSlug', () => {
test('lowercases and collapses non-alphanumeric to hyphens', () => {
assert.strictEqual(toWorkstreamSlug('My Feature Branch'), 'my-feature-branch');
assert.strictEqual(toWorkstreamSlug('hello_world'), 'hello-world');
assert.strictEqual(toWorkstreamSlug('API v2'), 'api-v2');
});
test('strips leading/trailing hyphens', () => {
assert.strictEqual(toWorkstreamSlug('--foo--'), 'foo');
assert.strictEqual(toWorkstreamSlug(' spaces '), 'spaces');
});
test('handles empty and nullish values', () => {
assert.strictEqual(toWorkstreamSlug(''), '');
assert.strictEqual(toWorkstreamSlug(null), '');
assert.strictEqual(toWorkstreamSlug(undefined), '');
});
test('handles already-valid slug', () => {
assert.strictEqual(toWorkstreamSlug('my-feature'), 'my-feature');
assert.strictEqual(toWorkstreamSlug('v2'), 'v2');
});
});
// ─── hasInvalidPathSegment ───────────────────────────────────────────────────
describe('workstream-name-policy — hasInvalidPathSegment', () => {
test('returns true for names with forward slash', () => {
assert.strictEqual(hasInvalidPathSegment('foo/bar'), true);
});
test('returns true for names with backslash', () => {
assert.strictEqual(hasInvalidPathSegment('foo\\bar'), true);
});
test('returns true for bare dot', () => {
assert.strictEqual(hasInvalidPathSegment('.'), true);
});
test('returns true for double dot', () => {
assert.strictEqual(hasInvalidPathSegment('..'), true);
});
test('returns true for names containing dot-dot sequence', () => {
assert.strictEqual(hasInvalidPathSegment('foo..bar'), true);
assert.strictEqual(hasInvalidPathSegment('../etc'), true);
});
test('returns false for valid workstream names', () => {
assert.strictEqual(hasInvalidPathSegment('my-feature'), false);
assert.strictEqual(hasInvalidPathSegment('v2'), false);
assert.strictEqual(hasInvalidPathSegment('feature.experimental'), false);
assert.strictEqual(hasInvalidPathSegment('alpha_1'), false);
});
test('handles empty and nullish values', () => {
assert.strictEqual(hasInvalidPathSegment(''), false);
assert.strictEqual(hasInvalidPathSegment(null), false);
assert.strictEqual(hasInvalidPathSegment(undefined), false);
});
});
// ─── isValidActiveWorkstreamName ─────────────────────────────────────────────
describe('workstream-name-policy — isValidActiveWorkstreamName', () => {
test('returns true for valid alphanumeric names', () => {
assert.strictEqual(isValidActiveWorkstreamName('feature'), true);
assert.strictEqual(isValidActiveWorkstreamName('v2'), true);
assert.strictEqual(isValidActiveWorkstreamName('my-branch'), true);
assert.strictEqual(isValidActiveWorkstreamName('feature.experimental'), true);
assert.strictEqual(isValidActiveWorkstreamName('alpha_1'), true);
assert.strictEqual(isValidActiveWorkstreamName('A1'), true);
});
test('returns false for names starting with non-alphanumeric', () => {
assert.strictEqual(isValidActiveWorkstreamName('-feature'), false);
assert.strictEqual(isValidActiveWorkstreamName('.feature'), false);
assert.strictEqual(isValidActiveWorkstreamName('_feature'), false);
});
test('returns false for names with path traversal', () => {
assert.strictEqual(isValidActiveWorkstreamName('..'), false);
assert.strictEqual(isValidActiveWorkstreamName('../etc'), false);
assert.strictEqual(isValidActiveWorkstreamName('foo..bar'), false);
});
test('returns false for names with slashes', () => {
assert.strictEqual(isValidActiveWorkstreamName('foo/bar'), false);
assert.strictEqual(isValidActiveWorkstreamName('foo\\bar'), false);
});
test('returns false for names with spaces', () => {
assert.strictEqual(isValidActiveWorkstreamName('my feature'), false);
});
test('returns false for empty string', () => {
assert.strictEqual(isValidActiveWorkstreamName(''), false);
});
test('returns false for nullish values', () => {
assert.strictEqual(isValidActiveWorkstreamName(null), false);
assert.strictEqual(isValidActiveWorkstreamName(undefined), false);
});
});
// ─── validateWorkstreamName (SDK alias) ──────────────────────────────────────
describe('workstream-name-policy — validateWorkstreamName (SDK alias)', () => {
test('is an alias for isValidActiveWorkstreamName', () => {
const testCases = [
'feature', 'v2', 'my-branch', '-bad', '', null, undefined,
'foo/bar', '..', 'foo..bar', 'A1', 'alpha_1',
];
for (const tc of testCases) {
assert.strictEqual(
validateWorkstreamName(tc),
isValidActiveWorkstreamName(tc),
`validateWorkstreamName and isValidActiveWorkstreamName should agree on: ${JSON.stringify(tc)}`,
);
}
});
});