The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.
Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
maxRetries (it owns no loop), so the test asserts the option contract
(recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
the helper, not approximated textually at every call site.
Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
destructured and aliased forms; escape hatch is inline
`// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
error-swallowing or name-colliding local teardown helpers) keep the raw call
with an inline eslint-disable + reason.
Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
- assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
STALE ids (a known offender was fixed but not pruned) — identity, not count,
and a ratchet DOWN toward zero.
- assertTightCeiling: a size/length budget whose ceiling must stay within a
grace band of the high-water mark, so budgets may only tighten, never creep.
Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
the remaining six patterns moved from integer baselines to named-set
allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
named filename sets (closes the swap-a-file-keep-the-count blind spot); a
module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).
Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
current high-water mark and an assertTightCeiling anti-creep check added per
tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
are intentionally left as-is — they are not grandfathered creeping budgets.
No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
4.2 KiB
JavaScript
136 lines
4.2 KiB
JavaScript
'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 { cleanup } = require('./helpers.cjs');
|
|
|
|
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 {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
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 {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
});
|