Files
msd-core/tests/bug-2943-config-get-context-window-default.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

147 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression test for bug #2943
*
* `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw
* "Key not found: context_window" when the key was absent from config.json,
* even though context_window has a documented schema default of 200000.
*
* Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map
* before emitting "Key not found", so schema-defaulted keys always return the
* default value (exit 0) when not explicitly set in the project config.
*/
'use strict';
// Migrated to typed-IR (#2974): the previous shape grepped stderr/stdout for
// "Key not found"; now the test passes `--json-errors` to gsd-tools and
// asserts on the structured `reason` code (a frozen-enum value from
// `core.cjs::ERROR_REASON`). Exit code is also a typed signal — together
// they fully discriminate the failure class.
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 os = require('node:os');
const { execFileSync } = require('node:child_process');
const GSD_TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
const { ERROR_REASON } = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'));
const { cleanup } = require('./helpers.cjs');
describe('bug-2943: config-get returns schema default for context_window', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
/**
* Run config-get with optional extra args. Returns { exitCode, stdout, stderr }.
* Uses --raw so we get the plain scalar value, not JSON-wrapped.
*/
function runConfigGet(keyPath, extraArgs = []) {
const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs];
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
// Windows/Node 22 under --test-concurrency=4 can starve subprocess slots when
// sharing a wave with bug-2760-codex-install (8–15s install subtests). 15s covers
// observed worst case (13.5s) with headroom.
stdout = execFileSync(process.execPath, args, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000,
});
} catch (err) {
exitCode = err.status ?? 1;
stdout = err.stdout?.toString() ?? '';
stderr = err.stderr?.toString() ?? '';
}
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
}
test('returns "200000" (exit 0) when context_window absent from config.json', () => {
// Fixture A: config with unrelated keys, no context_window
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('context_window');
assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)');
assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000');
});
test('returns configured value when context_window is explicitly set', () => {
// Fixture B: config has context_window: 1000000
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ context_window: 1000000 })
);
const result = runConfigGet('context_window');
assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key');
assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default');
});
test('--default flag overrides schema default', () => {
// config has context_window but we pass --default with a different value —
// when key IS present, real value wins over any default
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('context_window', ['--default', '123456']);
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided');
assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default');
});
test('errors with reason=CONFIG_KEY_NOT_FOUND (exit 1) for an unknown absent key — no regression', () => {
// An unrecognised key with no schema default still errors as before.
// Migrated #2974: assert on the structured reason code from --json-errors,
// not on substring presence in stderr/stdout text.
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('totally_unknown_key_xyz', ['--json-errors']);
assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key');
let parsed;
try {
parsed = JSON.parse(result.stderr);
} catch (err) {
assert.fail(`expected JSON-shaped stderr from --json-errors; got: ${JSON.stringify(result.stderr)}`);
}
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, ERROR_REASON.CONFIG_KEY_NOT_FOUND,
`expected reason=${ERROR_REASON.CONFIG_KEY_NOT_FOUND}, got=${parsed.reason}`);
});
test('--default flag still works for arbitrary absent keys', () => {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({})
);
const result = runConfigGet('some.missing.key', ['--default', '200000']);
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied');
assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value');
});
});