Files
msd-core/tests/bug-3631-router-raw-flag.test.cjs
Tom Boucher a28dcec981 chore(#597): replace count-based ratchet guards with AST lint + named-set allowlists (#603)
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>
2026-06-01 22:43:49 -04:00

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);
}
});
});