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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
169
tests/bug-3406-stale-sdk-shadow-detect.test.cjs
Normal file
169
tests/bug-3406-stale-sdk-shadow-detect.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
150
tests/bug-3579-graphify-hook-publish.test.cjs
Normal file
150
tests/bug-3579-graphify-hook-publish.test.cjs
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
90
tests/bug-3588-npm-audit-clean.test.cjs
Normal file
90
tests/bug-3588-npm-audit-clean.test.cjs
Normal 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}`);
|
||||
});
|
||||
});
|
||||
134
tests/bug-3631-router-raw-flag.test.cjs
Normal file
134
tests/bug-3631-router-raw-flag.test.cjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>/);
|
||||
});
|
||||
});
|
||||
|
||||
93
tests/cjs-sdk-bridge-integration.test.cjs
Normal file
93
tests/cjs-sdk-bridge-integration.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
217
tests/decisions-generator.test.cjs
Normal file
217
tests/decisions-generator.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
374
tests/lint-docs-required.test.cjs
Normal file
374
tests/lint-docs-required.test.cjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
369
tests/lint-shared-module-handsync.test.cjs
Normal file
369
tests/lint-shared-module-handsync.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
507
tests/phase-6-cjs-sdk-seam-contracts.test.cjs
Normal file
507
tests/phase-6-cjs-sdk-seam-contracts.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
196
tests/plan-scan-generator.test.cjs
Normal file
196
tests/plan-scan-generator.test.cjs
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
195
tests/schema-detect-generator.test.cjs
Normal file
195
tests/schema-detect-generator.test.cjs
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
136
tests/secrets-generator.test.cjs
Normal file
136
tests/secrets-generator.test.cjs
Normal 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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
145
tests/workstream-name-policy-generator.test.cjs
Normal file
145
tests/workstream-name-policy-generator.test.cjs
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user