Commit Graph

2 Commits

Author SHA1 Message Date
Tom Boucher
9faacc0c15 test(#3148): bound the long tail and delete the unbounded-spawn allowlist (#3192)
* test(#3148): bound the long tail and delete the allowlist

Migrates the final 170 unbounded sync spawn sites across 49 files, then
removes the allowlist entirely. local/no-unbounded-spawn now runs with no
exemption surface across tests/**: there is no file to add a name to.

drift-detection's throw-native git() helper routes to gitOrThrow -- bare
runGit would have taken 16 call sites quiet on failure. commands.test.cjs
has two independently-scoped runGsdTools/runCli helpers, one already bounded
and one not; they are kept distinct rather than unified, the same trap as the
two same-named git() helpers in Wave 1.

runNpm's bound was erasable. Its options spread callerOptions after the
defaults, so an explicit timeout:undefined silently dropped the 180000ms
bound -- the rule flagged it and was right; it was not a false positive. Fixed
by destructuring with a default, with a test that fails when the default is
removed.

Two sites stay on a raw spawn with an explicit timeout because the seam
cannot express them: one needs shell:true for npm.cmd on Windows, one
redirects stdout to a real fd. Both are the rule's own documented second
option, not an escape from it.

Closure verified rather than asserted: the derivation scan reports 0 unbounded
spawn helpers and 0 unbounded direct git call sites, and a temporary file
carrying an unbounded spawn still errors with the allowlist gone.

Closes #3064.

* test(#3148): close a hole in the guard's own eslint-disable ban

The ban listed only the top level of tests/, so it was blind to 37 .cjs
files under tests/helpers, qa, observability, fixtures and dispatch. With the
allowlist deleted this test is the sole remaining way to detect someone
silencing the rule inline, so the gap was load-bearing: a nested file could
carry an unbounded spawn plus an eslint-disable and pass everything.

Proven before and after. A probe planted under tests/helpers with both was
invisible to the guard and clean under eslint; after making the listing
recursive the guard fails on it. The scanned set goes from 771 files to 808.

Pre-existing since the guard shipped, but this wave is what promoted it to
sole defense, so it is fixed here rather than filed.

Also converts the last hand-rolled throw check to throwIfFailed and the last
re-derived legacy shape to compose toLegacyResult, which makes the epic's
none-remain claim true rather than nearly true. toLegacyResult itself is not
widened -- eight callers depend on its shape and one consumer does not
justify changing a shared contract.

* fix(#3148): correct seam incoherence at the bound and a slow review-lane error path

Two real failures from the remote runner, both fixed at the cause.

The seam could return outcome TIMED_OUT together with exitCode 0. At the
exact bound spawnSync reports ETIMEDOUT while the child has already exited
with a real status, and toSeamResult classified on the error code while
passing status straight through -- an incoherent pair its own boundary test
was written to catch, and did. A status that is not null is direct evidence
the child exited on its own, so it now decides the outcome before the
error-code branches run. process-seam.cjs was deliberately untouched by every
earlier wave; this is a defect in the module itself, kept surgical, with a
unit test that fails against the old logic.

review-lane with an unknown subcommand fell through to its usage error only
after loading the capability registry and building a per-lane plan, which
spawns one child process per lane -- up to twelve. The error path took
~1288ms instead of ~119ms, and under bench load it outran a caller's spawn
timeout and was killed before writing anything, which is the empty stdout and
stderr CI saw. It now fails fast before any of that work begins.

This is the epic's first production change. It is user-facing, so it carries
a changeset rather than a no-changelog label.

* test(#3148): replace a real-race timeout test with a deterministic one

E9 raced git rev-parse against a 1ms bound and assumed git always lost. On a
warm container git finishes first, spawnSync returns status 0 with no error
at all, the seam correctly classifies EXITED, and gitOrThrow correctly does
not throw -- so the test failed on both lanes. A probe confirms a genuine
timeout always carries status null, so this was never the seam misbehaving.

Raising the bound would only lengthen the odds, which is the same defect with
better luck. The test now drives gitOrThrow against a stubbed runGit that
returns a synthetic TIMED_OUT result, so it asserts exactly what it always
meant to -- that a timeout propagates as a throw -- with no timing
dependence. Five consecutive runs are identical where the old one varied.

I wrote this test in Wave 0; it is a real-race test by construction and
CLAUDE.md says to replace those rather than re-run them.

* chore(#3148): backfill changeset PR number 3192

---------

Co-authored-by: sim <sim@local>
2026-08-07 21:03:50 -04:00
Tom Boucher
07de60523c fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts (#3011)
* test(#2657): add failing-first regression for tracked bin/lib compiled artifacts

Nine gsd-core/bin/lib/*.cjs artifacts are tracked in git despite having
src/*.cts sources, violating ADR-457's build-at-publish contract. This
regression test asserts the ADR-457 end state (untracked, gitignored,
empty-set reported by the #2656 sync guard) and fails until the tracking
is fixed.

* fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts

Nine gsd-core/bin/lib/*.cjs artifacts (api-coverage, assumption-delta,
claude-orchestration, claude-orchestration-command-router, external-job,
markdown-table, runtime-artifact-install-plan, state-transition,
write-set) were tracked in git despite each having a matching src/*.cts
source, letting the committed bytes drift silently from source (#2653
demonstrated this for api-coverage.cjs).

Seven had no .gitignore entry at all; two (markdown-table.cjs,
write-set.cjs) had a pattern added by #2248 but were never
git rm --cached. Both gaps produce the same tracked-file symptom.

Untracks all nine and adds the seven missing .gitignore entries next to
their two siblings, reaching ADR-457's end state: bin/lib/*.cjs is a
gitignored build artifact built via prepare/pretest/prepublishOnly, never
checked-in source of truth. The #2656 artifact-sync guard is
regime-agnostic by design and needed no code change; it now reports the
empty-set end state.

* test(#2657): consolidate repeated still-tracked/unmatched assertion shape

Code-review finding (Standards axis, Duplicated Code): the three
'none of the nine should still be in bad state X' checks shared an
identical filter-then-assert-empty shape. Extracted assertNoneStillBad()
as a shared helper; behavior is unchanged.

* fix(#2657): make the .gitignore-match assertion existence-independent

The regression test's check-ignore assertion used --no-index, which
locally exercises the pattern correctly but was reported failing on
gsd-test's fresh shallow clone. Switched to plain 'git check-ignore -q'
(no --no-index): verified via a real git worktree checkout at both
origin/next (fails: all nine report not-ignored, since check-ignore
correctly special-cases the still-tracked pre-fix state) and this
branch's tip (passes: all nine report ignored). Plain check-ignore is
also semantically stronger than --no-index here, since it honors the
'a tracked path is never reported ignored' rule that --no-index
bypasses -- exactly the property under test for the two paths whose
.gitignore pattern predates this fix (#2248) but were never untracked.

Also reconciled the .gitignore comment: it previously read 'these
seven' beside seven new lines with no indication of the other two (of
nine total) that already had a pattern from #2248. Annotated both
groups so the count is unambiguous at the point of the diff.

* test(#2657): make every git invocation self-diagnosing

b6f915bc0 failed in the runner with a shape that turned out not to be
about .gitignore content or the merge: two of the five failures in this
file were 'Command failed' / 'Got unwanted exception' -- git itself
erroring, not answering. The old code used execFileSync + try/catch,
which conflates 'git said no' with 'git could not run' -- both looked
like the same negative result to the test, exactly the failure mode
that produced 'unmatched: <all nine>' twice on two different
assertions for two different reasons.

Switched every git invocation to spawnSync (never throws) and made
every assertion check the exit status explicitly before interpreting
output:
  - git ls-files: must exit 0, or the assertion fails loud with cwd,
    exit status, and stderr instead of silently reading an error as
    'nothing tracked'.
  - git check-ignore -q: only exit 0 (ignored) and exit 1 (not
    ignored) are legitimate answers per check-ignore(1); any other
    status is now a thrown infrastructure failure, never read as
    'not ignored'.
  - trackedCompiledArtifacts(): a thrown error is now reported as
    what it is (its internal git call failed), not swallowed into a
    'still tracked' verdict.
  - the sync-guard subprocess check now reports cwd/stderr/stdout on
    a non-zero exit instead of a bare doesNotThrow.

This is a genuine, independent test defect (a test that reads a
failed command's empty output as a meaningful answer can pass or fail
for the wrong reason) as well as the mechanism for finally surfacing
why b6f915bc0 failed in the runner: the next run's assertion messages
will show the resolved cwd and git's actual stderr instead of an
opaque 'unmatched: <all nine>'.

* fix(#2657): trust the repo root for git calls under dubious-ownership

Root cause of the failing runner verdict, harvested from the
diagnostics commit: every git invocation in the container exits 128
with 'fatal: detected dubious ownership in repository at /work' --
the checkout there is owned by a different uid than the process
running the tests, and git refuses to operate at all. The old
assertions read that hard failure as 'not ignored' / 'still tracked',
producing the all-nine symptom seen on both b6f915bc0 (plain
check-ignore) and 34052f836 (--no-index). Nothing was ever wrong with
the untracking, the .gitignore content, or the merge -- confirmed by
exhaustive local reproduction (git worktree, real shallow clone, the
runner's exact clone+checkout+merge sequence from its own Go source)
that could never surface the bug because this machine owns its own
checkouts.

Fixed at both git() call sites in this exact seam by passing
'-c safe.directory=<repo root>' per-invocation (never written to any
config file, so trust is scoped to the single call):
  - tests/fix-2657-untrack-compiled-artifacts.test.cjs
  - scripts/lint-compiled-artifact-sync.cjs -- a SHIPPED script with
    the identical defect (its own git ls-files failed the same way in
    the same run), which would fail identically for any containerized
    CI lane whose checkout uid differs from the running user, not just
    this branch. Folded in under the no-defer rule rather than filed
    separately, since it sits in the exact tracked-compiled-artifact
    guard this issue is about.

The status-code guards added in 31858818e stay in place -- they are
what turned an unexplainable 'unmatched: <all nine>' into a one-line
diagnosis, and they must keep any future infrastructure fault from
silently reading as a substantive result.

* chore(#2657): backfill changeset PR number to 3011

* chore(#2657): backfill changeset PR number to 3011

---------

Co-authored-by: sim <sim@local>
2026-08-02 21:22:43 -04:00