Commit Graph

3 Commits

Author SHA1 Message Date
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
Tom Boucher
199251c0f6 test(#432): make perf-316/perf-407 lock-race tests deterministic (#450)
The perf-316 and perf-407 regression tests had two race-driven failure modes:

1. False-fail: setTimeout(80) before assert.ok(fs.existsSync(lockPath)) could
   fire before Worker A finished writing the lock file under CI load. The
   handoff noted this fired on origin/next's coverage job.

2. False-pass (latent): if Worker B raced past Worker A's release before
   retrying, sabCount === 1 from the no-retry success path matched what
   the PRE-FIX buggy code produced (one SAB for the single successful
   open/write). The existing test had no witness for retry-path coverage,
   violating test-rigor Contract 4 (exercise the path you claim to cover).

Fix (test-only):

- Replace setTimeout(80) with await-{pid}-message synchronization. Worker A
  posts {pid} AFTER fs.writeFileSync/openSync returns (single-thread source
  order within the worker), so by the time the parent receives it the lock
  file exists on disk. The MessagePort buffers messages posted before the
  parent attaches its listener, so there is no listener-race.
  Ref: https://nodejs.org/api/worker_threads.html#event-message_1

- Add a 5000ms safety timeout on the lock-written signal so a hung Holder
  worker surfaces as a specific error, not a generic test-timeout.

- Add a fs.openSync/fs.writeFileSync stub in Worker B that counts atomic-
  create attempts (O_CREAT|O_EXCL for perf-316 / { flag: 'wx' } for perf-407).
  Assert lockAttempts >= 2 to prove the SUT entered the retry loop. This
  closes the Contract-4 hole: sabCount === 1 now discriminates pre-fix
  (sabCount === lockAttempts) from post-fix (sabCount === 1, hoisted).

- Bump holdMs from 400ms to 1000ms to guarantee >=4 retries (perf-316,
  200ms+jitter delay) or >=9 retries (perf-407, 100ms delay) on the
  slowest CI worker. Test wall time grows ~600ms; well under the existing
  8000ms timeout.

Closes #432

Co-authored-by: CI Rebase Check <ci@open-gsd.dev>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:08 -04:00
Tom Boucher
1868947088 perf(#316): hoist state-lock sleep buffer out of retry loop (#399)
acquireStateLock was allocating a fresh SharedArrayBuffer on every retry
iteration via Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), ...).
The buffer is never mutated and never escapes, so hoisting it before the loop
is a provably-equivalent transformation — Atomics.wait always sees value 0
whether the buffer is fresh or reused.

Fixes #316

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:21:52 -04:00