Conversation
Apply reviewer feedback and reframe interpretation-derived tests as blocking direct conformance, with the underlying SPEC ambiguities now tracked in SPEC-PROBLEMS.md for resolution in a follow-up cycle. TEST-SPEC.md changes: - withFakeNpm: spawnFailure must produce real ENOENT via PATH control (no dispatcher shim). - T-INST-112c: drop impossible non-overlap assertion (only one invocation reaches the shim under that fault config); sequential timing is exercised by T-INST-110 / T-INST-114. - T-API-62i / T-API-62i2: tighten fixtures so non-signal getters return values the implementation actually consumes. - T-API-68i / T-API-69h: add target-syntax and throwing-env-entry-getter variants to close SPEC 9.1 carve-out enumeration. - T-API-64j / T-API-64j2: parameterize across missing / non-readable / non-boolean aborted variants. - New tests: T-MOD-03f-bun (Bun intermediate-ancestor resolution), T-MOD-03g / T-MOD-03g-bun (cwd-independence under both runtimes), T-EXEC-13f / T-EXEC-13g (CJS rejection on .jsx / .tsx), T-SUB-11e (empty env-var name rejection). - Drop the "non-gating" / "Boundary/derived (not direct conformance)" framing from interpretation-derived and grammar-derived tests; they are now blocking direct-conformance tests. - Update Appendix A traceability matrix to include new test IDs. SPEC-PROBLEMS.md: rewrite from scratch to track the SPEC ambiguities that drive the remaining interpretation-encoding tests (HTTP 3xx handling, missing/non-boolean aborted on duck signals, commit-phase "completes" wording, abort × maxIterations: 0, whitespace-only env lines). Workflow updates: - review-test-spec/index.sh: include SPEC-PROBLEMS.md in the reviewer prompt and ask the reviewer to also work on resolving open problems. - review-test-spec/apply-feedback.sh: tell Claude to add newly found SPEC problems to SPEC-PROBLEMS.md and delete the file when no problems remain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Also update review-test-spec workflow to allow proposing/applying SPEC.md changes scoped to resolving SPEC-PROBLEMS.md entries (with user verification). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves five SPEC-PROBLEMS.md ambiguities with narrow SPEC.md edits (HTTP 3xx redirects, duck signal aborted contract, commit-failure auto-install gating, abort × maxIterations: 0 precedence, whitespace-only env-file lines), promotes the corresponding TEST-SPEC tests from ambiguity-encoding to direct conformance pins, adds T-CLI-19b for the SPEC 7.2 .loopx/ creation-guidance contract, softens T-INST-92c and T-INST-87 to avoid pinning unspecified behavior, and parameterizes T-API-64k3 across run() and runPromise(). Tracks the previously-untracked install-source symlink copy semantics gap (T-INST-55f / T-INST-55g) as a new SPEC-PROBLEMS entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve the SPEC-PROBLEMS.md install-source-symlink ambiguity by adding SPEC 10.11 (Install Source Symlinks) and promoting T-INST-55f / T-INST-55g from known-gap to executable tests, plus T-INST-55h / T-INST-55i for broken-symlink rejection and -w selective validation. Add T-CLI-RUN-ORDER-05 for env-file-failure ordering vs workflow package.json warnings, T-DEL-04a for the LOOPX_DELEGATED recursion guard with a broken project package.json, and parameterize T-DEL-28/T-DEL-28a across devDependencies and optionalDependencies as T-DEL-28b–T-DEL-28e to close the field-parity gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix three TEST-SPEC issues flagged by review (T-INST-55p materialization contradiction, T-API-64k3 over-pinning of listener registration, T-API-65p/q maxIterations spread overwriting variants), add eleven new tests covering install symlink-policy gaps (T-INST-55q–55t), source-detection asymmetry (T-INST-05a), tarball wrapper-stripping edge case (T-INST-83b), abort-precedence for non-string targets (T-API-65a2), npm-install SIGINT parity (T-INST-116f, T-INST-116g), and version-subcommand grammar (T-CLI-01a, T-CLI-01b). The version-subcommand tests encode the most-natural reading of a SPEC ambiguity that is now tracked in SPEC-PROBLEMS.md for follow-up clarification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve ADR via shared/resolve-adr.sh in both index.sh and apply-feedback.sh, and instruct the reviewer and Claude to propose / apply SPEC.md and SPEC-PROBLEMS.md changes only when scoped to that ADR. Remove SPEC-PROBLEMS.md whose only entry (`loopx version` argument grammar) was unrelated to ADR-0004. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove stale SPEC-PROBLEMS.md references (file is absent; the version- subcommand grammar tracking note is reframed as a most-natural reading outside ADR-0004 scope). - Add T-TMP-37c (hard-link cleanup-safety: rule-3 nlink guarantee). - Add T-INST-119a-stderr (real-time npm stderr passthrough). - Add T-INST-114d (mixed safeguard / non-zero / spawn-failure aggregate). - Add T-API-56c/d/e/f (null-prototype env, sharper ignored-key getters). - Add T-API-61i/j (null-prototype options accepted on both run surfaces). - Add T-ENV-24a6 (LOOPX_DELEGATED four-tier ordinary precedence). - Add T-CLI-RUN-INHERIT-01 (positive shell-env-prefix CLI surface). - Update Appendix A traceability rows for the new tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix SPEC 8.3 tier numbering in T-CLI-RUN-INHERIT-01 (inherited env is tier 5, not tier 1) and T-ENV-24a6 (RunOptions.env tier 2, local 3, global 4, inherited 5; flipping the prior reversed labels). - Add T-API-52c/d/e: positive RunOptions.env entry-snapshot tests proving own enumerable string-keyed properties are read once at call time (run, runPromise, and proxy-success ownKeys/get counter variant). - Extend T-API-67b/c/f/g pinned-priority coverage to include the env-file-loading branch alongside discovery / target / tmpdir. - Tighten T-INST-119 scope: forbid loopx-added progress/status lines on the success path (drop prior "Installing <workflow>..." example) while preserving permitted SPEC-defined auxiliary output (T-INST-113 / 112c / 114 / 120). - Add explicit pre-create language to T-TMP-25/26/27, T-TMP-29 and the -29a/b/c/d/e tier variants so test setup of TMPDIR / TEMP / TMP parents is unambiguous. - Add T-TMP-26-temp/-tmp and T-TMP-27-temp/-tmp programmatic snapshot-timing variants closing the lazy/eager × TEMP/TMP matrix. - Add T-SYM-04c: workflow-dir / Bash-$0 consistency under absolute RunOptions.cwd trailing-slash and lexical-`..` spellings, asserting SPEC 6.2 byte-for-byte Bash equality without over-pinning lexical normalization. - Update Appendix A traceability rows and Summary of Special Values for the new test IDs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add T-INST-44e: late-help duplicate --no-install short-circuit coverage - Extend T-API-55c/55d with throwing options.env getter variant under maxIterations: 0 - Strengthen T-API-52e to assert exact ownKeys/value-read counts (was loose bounds) - Add T-API-52e2: non-ownKeys proxy trap (descriptor / get) success-path coverage - Add T-API-62f2 / 62f3: throwing descriptor / get trap snapshot-error variants - Add T-API-62h9: no-retry contract for throwing get trap - Extend T-API-68i / 69h carve-out variants with descriptor / get trap throws - Add T-API-65u: reentrant duck signal x later pre-iteration failure abort precedence - Add T-SYM-02c: CLI symlinked-project-root LOOPX_WORKFLOW_DIR / Bash \$0 coverage - Narrow T-INST-119 progress-indicator wording to spinners / progress bars / progress phrases (was forbidding any "what loopx is doing" output, beyond SPEC 10.10) - Add SPEC-PROBLEMS.md with P-0004-01: non-regular pre-existing .gitignore behavior ambiguity in SPEC 10.10 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve P-0004-01 by pinning down the .gitignore safeguard's lstat-regular dispatch in SPEC §10.10, then close out the corresponding TEST-SPEC coverage (directory + --no-install short-circuit + symlink-via-seam sub-cases) and strengthen T-INST-110's sequential-ordering assertion with a 1-second sleep that makes the non-overlap check non-vacuous. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix T-API-55c/55d/65p/65q to construct option objects directly instead of via object-spread, so throwing getters/proxies are preserved as accessors on the object passed to run()/runPromise() rather than being invoked at the test call site. Add a general "test-construction sanity for getter/proxy variants" note in §1.3 explaining the rule. Add T-INST-112h (top-level .gitignore lstat failure other than ENOENT via a new gitignore-lstat-fail seam), T-INST-112i (FIFO .gitignore via gitignore-replace-with-fifo seam), and T-INST-112j (socket .gitignore via gitignore-replace-with-socket seam) to close the previously-tracked SPEC 10.10 safeguard-failure coverage gaps. Update §1.4 seam table and Appendix A row 10.10 accordingly. Create SPEC-PROBLEMS.md with P-0004-02: signal handling during the post-commit auto-install pass when no npm install child is currently active (SPEC 10.10's "Signals during npm install" clause covers only the active-child case). TEST-SPEC.md does not add conformance tests for the no-active-child windows until the clause is clarified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve P-0004-02 with a SPEC 10.10 clarification covering signal handling during the auto-install pass when no npm child is active, delete SPEC-PROBLEMS.md, fold the resolution note into TEST-SPEC §9, and apply four targeted TEST-SPEC.md refinements: T-API-62f2 and the descriptor-trap variants of T-API-68i / T-API-69h are reframed as observational (descriptor-trap invocation is implementation-defined, not a SPEC requirement) and dropped from direct traceability claims; T-API-51f closes the run() full env-precedence-chain surface gap; T-INST-112k records the regular-but-unreadable .gitignore permission-non-inspection sub-path as a known gap with a recommended seam; and T-TMP-12d / T-TMP-12e known-gap notes now subsume the signal/abort × creation-failure race forms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convert T-INST-116h/i/j/k to ordinal pause windows so they no longer assume an unspecified commit order; define the parent-observable JSON marker contract for the LOOPX_TEST_AUTOINSTALL_PAUSE seam; clarify the gitignore-make-unreadable seam's mode-restoration responsibility (loopx leaves mode 000, harness restores in afterEach); add T-TMP-13b for run() generator natural-settlement cleanup, T-TMP-18b for SIGINT escalation + tmpdir cleanup, and T-API-10f-throw for active-child .throw() process-group termination; update Appendix A traceability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests / known-gap notes for ADR-0004 coverage edges identified in review: - T-API-66c/d/e + T-TMP-24e/f/g: abort-after-final-yield variants for the `stop: true`-driven final-yield trigger (existing T-API-66/66a/66b + T-TMP-24a/24c/24d only covered the `maxIterations`-reached trigger). - T-API-10c4 + T-API-10c5: live signal that aborts after `run()` / `runPromise()` returns × `maxIterations: 0` (complementing the pre-aborted-signal coverage in T-API-10c2 / T-API-10c3). - T-API-64m + T-API-64m2: duck-signal `addEventListener` getter throws on read (distinct from the function-throws-on-call case in T-API-64f / T-API-64g). - T-INST-116l: known-gap note for the "aggregate report already emitted" carve-out half of SPEC 10.10's signal-termination clause, with implementer note for a `post-aggregate-report` pause-seam value. - T-TMP-35f/g/h: cleanup-warning-does-not-affect-outcome coverage on the signal-terminal (CLI) and abort-terminal (`runPromise()`, `run()`) surfaces. - Tmpdir Parent Snapshot Timing: literal-path expectation preface reinforcing `fs.mkdtemp`-allocated parents over fixed `/tmp/...` placeholders for T-TMP-25 onward. No SPEC.md changes — review found no ADR-0004 ambiguities requiring spec edits, and SPEC-PROBLEMS.md remains absent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track T-INST-116l (already-emitted aggregate-report carve-out) as a known ADR-0004 gap consistently across §1.3, §9 P-0004-02 resolution note, and the Appendix A SPEC 10.10 row. Fix the T-TMP-12-cli subcase count from "four" to "five". Add run() generator counterparts for env-file NUL spawn failures (T-ENV-26f, T-ENV-26g, T-ENV-27d, T-ENV-27e) so the local/global env-file tier is covered on all three run surfaces. Add T-CLI-RUN-DASHDASH-10/11 to close the "-- as option operand" parser gap (loopx run -e -- ralph and loopx run ralph -e --). Relax T-INST-119 to forbid only actual progress indicators (spinners, progress bars, percentages); plain status lines are not asserted against absent a SPEC 10.10 clarification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add new tests and strengthen existing ADR-0004 coverage based on review feedback against SPEC.md: - T-API-59b / T-API-59c: run() generator-surface counterparts to T-API-59 / T-API-59a (RunOptions.env does not redirect global env-file path resolution via XDG_CONFIG_HOME / HOME). - T-API-60b / T-API-60c: run() generator-surface counterparts to T-TMP-29b / T-TMP-29c (RunOptions.env does not redirect tmpdir parent via TEMP / TMP). - T-API-65v / T-API-65w: invalid options wrapper + aborted signal intersection on runPromise() and run() — pin down the SPEC 9.3 carve-out that an invalid options value captures no usable signal even when the wrapper carries an aborted AbortSignal. - T-API-51a: strengthen with in-script stat of LOOPX_TMPDIR matching T-TMP-30 / T-TMP-31 rigor (proves the protocol-injected value is the real loopx-created tmpdir, not a string substitution). - T-INST-119 / T-INST-119a / T-INST-119a-stderr / T-INST-119b: tighten npm stdout/stderr passthrough assertions from substring match to exact-standalone-line match, so a buggy implementation that prefixed npm lines (e.g., "[npm] npm-stdout-MARKER\n") is caught. SPEC-PROBLEMS.md remains absent — no ADR-0004-scoped SPEC ambiguity needs to be tracked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Clarify post-exit-first/post-exit:<workflow> seam definition (§1.4) to guarantee the npm child's exit and any non-zero-exit aggregate failure entry are recorded before the pause begins, so T-INST-116j2 actually exercises the "accumulator already holds an npm-non-zero entry" case. - Fix global-env-file SPEC 8.2 → 8.1 references in T-TMP-12-global-env- unreadable, T-TMP-12-cli-global-env-unreadable, T-API-65r, T-API-68o, T-API-69n, and T-SIG-30, plus appendix traceability rows: move T-API-68o, T-API-69n, T-SIG-30 from row 8.2 to row 8.1; add 8.1 to T-TMP-12 and T-TMP-12-cli parent-trailer Spec lists. - Add T-TMP-12f4: starting-workflow unreadable package.json warning runs before tmpdir creation, closing the fourth SPEC 3.2 package.json failure-mode branch (the unreadable-file branch was previously left as an implicit corollary of T-VER-07). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- T-API-65u: drop the throwing-cwd-getter variant. SPEC 9.5 leaves the ordering between reading aborted and registering the duck listener implementation-defined, so an impl that reads aborted (false) and then captures the cwd-getter throw without registering the listener is conformant. The "abort beats option-snapshot failure" axis is already covered for real pre-aborted AbortSignals by T-API-65p / 65q. Keep the env-file and target-resolution variants where pre-iteration timing makes the abort precedence observable independent of intra- snapshot ordering. - T-CLI-RUN-DASHDASH-10/11: replace the "no MARKER leaked into spawned env" assertion (not independently observable when no child spawns) with an absence-of-SPEC-8.1-invalid-key-warning assertion. The "--" file now contains a malformed line "1BAD=warning-if-loaded" so an impl that loaded it before usage validation would emit the SPEC 8.1 warning to stderr regardless of any subsequent spawn. - T-API-56g, T-API-56h: add positive coverage for non-plain RunOptions.env objects. T-API-56g pins the structural-not-nominal contract via a class instance; T-API-56h pins that Map is accepted as a shape but contributes zero env vars (Map entries aren't own enumerable string-keyed properties). - T-INST-113c2: parameterize the multi-workflow malformed-package.json continuation rule over the remaining SPEC 3.2 / 10.10 causes (unreadable, invalid-semver, non-string loopx range value), so the SPEC 10.10 "other workflows still proceed" clause is exercised independently per cause. Trace matrix updated for SPEC 3.2, 8.1, 9.5, 10.10. No SPEC.md or SPEC-PROBLEMS.md changes — feedback explicitly notes no ADR-0004- scoped SPEC ambiguity requires resolution this cycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three test groups closing residual ADR-0004 coverage gaps that were identified in the post-acceptance TEST-SPEC review: - T-API-21h/i/j: runPromise() counterparts for call-time relative envFile and cwd resolution (mirroring T-API-21f/g on the run() surface), including the combined relative-cwd × relative-envFile case unique to runPromise()'s eager pre-iteration snapshot. - T-SYM-07b: Bun counterpart for the symlink-free import.meta.url vs. LOOPX_WORKFLOW_DIR byte-for-byte equality from SPEC 6.3, with the symlink-divergence assertion intentionally omitted (SPEC 6.3 does not require Bun to match Node's realpath behavior). - T-CLI-RUN-DASHDASH-12/13: -n operand-position counterparts to T-CLI-RUN-DASHDASH-10/11, closing the "-- cannot slip through as an option operand" axis for the second run option that consumes an operand. Also updates the Appendix A traceability matrix rows 4.1, 4.2, 6.1, 6.3, 9.2, 9.5, and 12 to reference the new test IDs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix T-MOD-03h / T-MOD-03h-bun extension-remapping reliance: import the nested helper as ./lib/helper.ts (its actual extension) instead of ./lib/helper.js, since SPEC does not specify tsx/Bun .js→.ts remapping. - Promote the optional run() concurrent-isolation companion in T-API-50f to a required test T-API-50g, pinning ADR-0004's no-racy-process.env- mutation motivation on both API surfaces. - Expand T-API-65p / T-API-65q (abort-precedence × option-snapshot) with the full enumerated invalid maxIterations set (Infinity, null, "1" added to -1, 1.5, NaN) and reword "all remaining ... failures" to "representative remaining ... failures" for the env shape/value branches, deferring exhaustive env-shape coverage to T-API-53–55 etc. - Apply the same expansion + rewording to the carve-out tests T-API-68i (.return()) and T-API-69h (.throw()). - Parameterize T-API-55a / T-API-55b over the full SPEC 9.5 invalid-env shape and entry-value matrix under maxIterations: 0, ensuring shape validation surfaces under the zero-iteration short-circuit on both API surfaces. No SPEC.md changes; no SPEC-PROBLEMS.md added (no ADR-0004-scoped ambiguity identified by this review cycle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix T-CLI-RUN-DASHDASH-08 and T-TMP-12-cli-usage / T-TMP-12-cli-help-with-unknown wording so help short-circuit tests no longer assert "no discovery"; SPEC 11.2 explicitly permits non-fatal run-help discovery/validation. Frame the assertions around "does not enter the execution pre-iteration sequence" and "no LOOPX_TMPDIR" instead.
- Broaden T-API-57b ("=" in RunOptions.env key) to assert only that loopx does not reject as option-shape error and that the spawn either fails or succeeds — drop the over-pinned "variable is observable" branch since runtime parsing of "BAD=KEY" is OS/runtime-dependent.
- Add T-API-72a covering runPromise() inherited-env snapshot reuse across iterations: mutate process.env.MYVAR between iterations under a release-file sentinel and assert both spawns observed the call-site snapshot.
- Add T-INST-110g pinning down auto-install on a tarball source — previous positive-trigger tests are source-type-agnostic in wording but may default to git fixtures, leaving the post-commit auto-install pass on the tarball path uncovered.
- Extend T-API-10h to observe LOOPX_TMPDIR via an external marker and assert tmpdir cleanup runs before generator settlement under SIGKILL escalation, closing the gap between T-TMP-21 (no escalation) and T-API-10g (.return() escalation).
- Strengthen T-TMP-12a / T-TMP-12d / T-TMP-12e with explicit "no cleanup-related stderr warning" assertions on the success-cleanup branches, pairing inversely with T-TMP-12d2 / T-TMP-12e2's positive cleanup-warning assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…25 (--no-install retrofit per SPEC §4.10 suite-wide auto-install-awareness rule) Five install-time test fixtures predate SPEC §10.10's auto-install pass and were invoking real `npm install` against fixtures during `loopx install`, which fails in the sandboxed test environment. Retrofit each install command with `--no-install` to scope the assertions to install- time version-check / delegation behavior only — mirroring the prior retrofit done on T-INST-60j / T-INST-73 / T-INST-73a / T-INST-80 / T-INST-91. All 14 affected T-VER invocations (4 IDs × ~3 sub-cases × 2 runtimes) and T-DEL-25 now pass; full version-check.test.ts (152) and delegation.test.ts (29) suites pass cleanly (181/181). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…28b / 28c / 28d / 28e / 29 / 30 (Delegation edge cases — SPEC §3.2 / §4.3 / §6.4 / §8.3 / §13) Adds 14 missing T-DEL test cases to apps/tests/tests/e2e/delegation.test.ts covering the §4.12 Delegation block from TEST-SPEC.md. Test-only iteration — existing implementation in `checkDelegation` + `hasLoopxDep` at packages/loop-extender/src/bin.ts:70-142 already conformed across all 14 IDs: - LOOPX_DELEGATED check at bin.ts:480 runs BEFORE any package.json read (T-DEL-04a), and uses presence-only semantics — any defined value (incl. "0") suppresses delegation (T-DEL-09a). - hasLoopxDep checks all three SPEC-3.2-enumerated fields (dependencies / devDependencies / optionalDependencies) and only validates "is the value a string" — no semver validation runs (T-DEL-15a/15b/28/28a/28b/28c/28d/28e). - Delegation fires before argv parsing at bin.ts:480-507, so usage-error inputs (--unknown, run --bogus) reach the local binary (T-DEL-30). - Project-root derivation uses bin.ts:477 process.cwd() exclusively; \$PWD is never consulted (T-DEL-29). - The output subcommand parser is invoked AFTER delegation, so \`loopx output --result foo\` is forwarded byte-for-byte (T-DEL-26a). - LOOPX_DELEGATED=1 set at bin.ts:491 propagates through env.ts:91-95 mergeEnv to spawned scripts via the inherited tier (T-DEL-07a). New helper \`createArgvRecorderBinary\` records argv one-per-line for byte-for-byte assertions (T-DEL-26a / T-DEL-30). delegation.test.ts goes from 29 → 43 tests, all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/ T-INST-120d (-y Replacement Auto-install Freshness — SPEC §10.10 / §10.5) Test-only iteration. Existing install.ts already conformed across all 5 IDs: removeFsEntry(destPath) at install.ts:370 unconditionally removes the pre-existing workflow directory before commit (clearing stale node_modules/ and any prior synthesized .gitignore); runAutoInstall fires per-committed- workflow regardless of how -y unblocked the commit; runGitignoreSafeguard synthesizes only on ENOENT and leaves regular files alone; the if (!noInstall) gate suppresses the entire auto-install pass (including the safeguard) without affecting the pre-commit -y removal. 5 unique IDs × 2 runtimes = 10 tests added to install.test.ts (492 → 502 tests). New test-local helper preSeedReplacementTarget(loopxDir, name) seeds .loopx/<name>/ with index.sh + package.json + node_modules/old-marker + .gitignore = "node_modules\\n", representing the on-disk state left by a prior loopx install + auto-install pass. T-INST-120e (5-variant malformed-replacement matrix) intentionally deferred — needs LOOPX_TEST_AUTOINSTALL_FAULT=package-json-make-unreadable: test seam plus non-regular-directory package.json/ source variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oopx auto-install-skip — SPEC §3.2 / §10.10)
Test-only iteration closing the standalone-devDependencies.loopx ×
invalid-/non-string-value × auto-install-skip diagonal that
T-VER-09a/11b2/11d/14b/14c/14d/12c/13b2/13d/15b left as the explicitly
deferred work.
Existing install.ts runAutoInstall already conformed:
- extractLoopxValue (version-check.ts:70) walks dependencies then
devDependencies, preserves type
- checkWorkflowVersion (version-check.ts:76-81) routes both
non-string values (via String()) and invalid-range strings (via
!isValidRange) through {kind: "invalid-semver"}
- runAutoInstall (install.ts:499) calls checkWorkflowVersion; the
"invalid-semver" switch case (lines 520-528) sets malformed=true
and emits a deduped warning; if (malformed) continue at line 535
skips both .gitignore safeguard and npm install spawn
Tests use withFakeNpm({exitCode: 0, logFile}) — added in T-INST-119
iteration — to assert fake.readInvocations().length === 0 and
existsSync(.gitignore) === false. Closes the prior cluster's
deferred work; comment at version-check.test.ts:1920 in T-VER-12c
already noted "T-VER-15c covers the auto-install-skip contract
explicitly".
2 unique IDs × 2 runtimes = 4 tests added; version-check.test.ts
152 → 156 tests; full-file run clean across both Node and Bun;
typecheck passes; zero regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…scovery Caching API surface counterparts — SPEC §5.1 / §7.2 / §7.4 / §9.1 / §9.2 / §9.3) Test-only iteration — existing implementation already conformed. run() (generator) variants 42d/42f use externally-driven rm/mv of the cached check.sh between next() calls inside the API driver script. runPromise() variants 42e/42g use in-loop self-modification because runPromise() buffers outputs internally and there is no externally observable yield to coordinate against. All 4 tests assert: spawn-failure throw / rejection (NOT a discovery error — discovery cached check.sh at loop entry), and no loopx-* residue under the test-isolated TMPDIR parent (cleanup ran before throw / rejection per runLoop's try/finally at loop.ts:155-283). 4 unique IDs × 2 runtimes = 8 tests added to discovery.test.ts (91 → 99 tests; full-file run clean across both Node and Bun; zero regressions). Closes the §4.3 Discovery Caching API-surface gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…js/.cjs coexistence with valid scripts + same-base-name collision immunity — SPEC §2.1 / §5.1 / §5.2 / §11.2) Test-only iteration; existing discovery.ts already conformed across all 4 IDs. SUPPORTED_EXTENSIONS at discovery.ts:5 is the single source of truth for recognized extensions; the SUPPORTED_EXTENSIONS.has(ext) filter at discovery.ts:117 silently excludes .mjs/.cjs from candidateScripts before collision detection (discovery.ts:162-172) or final scripts-map construction (discovery.ts:175-180) sees them. So a .mjs/.cjs sibling of a valid script is silently ignored at both run-time and help-time, and a .mjs/.cjs same-base sibling of a discovered script never triggers collision detection. - T-DISC-07a: .mjs sibling of valid index.sh — ralph runs, ralph:helper not found, no warning about helper.mjs (mirror of T-DISC-07b for .cjs). - T-DISC-07c: help-mode counterpart — loopx run -h with both .mjs and .cjs siblings shows ralph with index only; helper/tool do not appear as script lines, and stderr has no helper.mjs/tool.cjs warning. - T-DISC-24a: check.ts + check.mjs in same workflow — ralph:check resolves to check.ts, no collision warning, marker written. - T-DISC-24b: check.ts + check.cjs counterpart of T-DISC-24a. 4 new tests added to discovery.test.ts (99 → 103); full-file run clean (103/103) in 8.58s. Typecheck clean on both packages/loop-extender and apps/tests. CLI-only, matching the surrounding T-DISC-07b / T-DISC-21..24 convention; install-time companions T-INST-42m / T-INST-42n were already in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(Symlink / Project-Root Spelling foundational block — SPEC §3.2 / §5.1 / §6.1 / §6.2 / §9.5)
Test-only iteration — existing implementation already conformed across all 5 IDs:
- LOOPX_PROJECT_ROOT and LOOPX_WORKFLOW_DIR are written into the spawn env
from cached discovery-time paths (execution.ts:182-184; workflow.dir =
join(loopxDir, entry) at discovery.ts:81; projectRoot is the cwd
snapshotted at run.ts call time). No realpath / canonicalization is
applied to either.
- Absolute RunOptions.cwd is used unchanged at run.ts:167-168
(isAbsolute(cwdRaw) ? cwdRaw : resolve(...)), preserving the
caller-supplied symlinked spelling.
- Discovery's statSync follows symlinks for type checks but the cached
workflow path is constructed via lexical join() — no realpath
canonicalization on the discovered workflow path.
5 unique IDs × forEachRuntime (Node + Bun) = 10 tests added to a new file
apps/tests/tests/e2e/symlink.test.ts (TEST-SPEC §4.7 Symlink /
Project-Root Spelling, line 2070):
- T-SYM-01 (CLI): symlinked .loopx/ runs end-to-end through the symlink.
- T-SYM-02 (CLI): LOOPX_PROJECT_ROOT byte-for-byte equals loopx's own
process.cwd() at invocation; the test pre-spawns the matching runtime
to compute expectedRoot dynamically per SPEC 3.2 / 6.1's "exactly the
string returned by process.cwd()" rule (so the assertion is conformant
whether the runtime canonicalizes via getcwd(3) or preserves the
symlinked spelling). Defensive: statSync(linkProject).{dev,ino} ==
statSync(realProject).{dev,ino}.
- T-SYM-03 (programmatic): RunOptions.cwd: linkProject preserves the
symlinked spelling verbatim — pins SPEC 9.5 "absolute cwd is used
unchanged" + SPEC 3.2 "no realpath".
- T-SYM-04 (programmatic): RunOptions.cwd: ${linkOuter}/proj preserves
the symlinked-ancestor spelling verbatim — closes the lexical-only
ancestor-prefix contract.
- T-SYM-05 (CLI): with .loopx itself a symlink, LOOPX_WORKFLOW_DIR equals
the cached <PROJECT_ROOT>/.loopx/ralph spelling — NOT the realpath-
canonicalized join(realpathSync(realLoopx), "ralph") form.
Verification: targeted symlink.test.ts → 10/10 in 931ms (Node + Bun).
Full e2e suite → 3826/3828 (1 skipped, 1 pre-existing T-INST-GLOBAL-01a
unrelated to ADR-0004). Typecheck clean on both
packages/loop-extender/tsconfig.build.json and apps/tests/.
Closes 5 of the 18 missing T-SYM-* IDs flagged in the §4.7 TDD-Gate
audit. Remaining variants (T-SYM-02c, 02d, 04a-04d, 06, 06a, 07, 07a,
07b, 08, 09 — workflow-dir symlinked-ancestor preservation, identity-vs-
spelling × CLI/programmatic matrix, absolute-cwd lexical edges, Node
import.meta.url + Bun counterparts, --preserve-symlinks flag-injection
negative, omitted-cwd × symlinked-process-cwd intersection) deferred
to a future iteration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-04c / T-SYM-04d / T-SYM-06 / T-SYM-06a / T-SYM-07 / T-SYM-07a / T-SYM-07b / T-SYM-08 / T-SYM-09 (Symlink / Project-Root Spelling variant cluster — SPEC §3.2 / §5.1 / §6.1 / §6.2 / §6.3 / §9.1 / §9.2 / §9.5)
Test-only iteration — existing implementation already conformed across all 13 IDs:
- Cwd preserved verbatim at run.ts:167-168 (absolute-cwd branch); loop.ts:73 threads
projectRoot to executeScript; execution.ts:182 writes LOOPX_PROJECT_ROOT directly.
- Workflow.dir = join(loopxDir, entry) at discovery.ts:81 (lexical, no realpath);
script.path = join(workflow.dir, fileEntry); Bash $0 = script.path absolute.
- No --preserve-symlinks(-main) injection: execution.ts:206-237 builds JS/TS spawn
argv from tsx --import LOADER_REGISTER_PATH script.path (Node) or bun --define
require:null [bunfig+jsx flags] script.path (Bun); scriptEnv (lines 179-203)
doesn't set NODE_OPTIONS. Grep across packages/loop-extender/src confirms zero
matches for preserve-symlinks / NODE_OPTIONS / execArgv.
13 unique IDs added to apps/tests/tests/e2e/symlink.test.ts after T-SYM-01..05:
- T-SYM-02c (CLI symlink-cwd × LOOPX_WORKFLOW_DIR + dirname "$0" tracking)
- T-SYM-02d (CLI symlink-cwd × effective-cwd device/inode identity)
- T-SYM-04a (programmatic absolute cwd × trailing-slash preservation)
- T-SYM-04b (programmatic absolute cwd × lexical .. preservation)
- T-SYM-04c (workflow-dir consistency under cwd-spelling edges, parameterized)
- T-SYM-04d (programmatic symlink-cwd × effective-cwd device/inode identity)
- T-SYM-06 (programmatic symlink-cwd × LOOPX_WORKFLOW_DIR symlink-preserving)
- T-SYM-06a (programmatic symlink-cwd × Bash $0 + dirname equality)
- T-SYM-07 [Node] symlink-free import.meta.url == LOOPX_WORKFLOW_DIR
- T-SYM-07a [Node] symlinked-entry observational envelope (no equality pin)
- T-SYM-07b [Bun] symlink-free counterpart of T-SYM-07
- T-SYM-08 [Node] no --preserve-symlinks(-main) in execArgv / NODE_OPTIONS
- T-SYM-09 (omitted-cwd × runPromise/run × symlinked-process-cwd, parameterized)
Vitest invocations: T-SYM-04c parameterized over trailing-slash + lexical-dotdot
(2 sub-tests per runtime); T-SYM-09 parameterized over runPromise + run (2 sub-
tests per runtime); T-SYM-07/07a/08 [Node-only] skip on Bun; T-SYM-07b [Bun-only]
skips on Node — yields 36 active + 4 runtime-skipped (40 total) on the 2-runtime
matrix. symlink.test.ts: 10 → 40 tests, all 36 active passing in 5.13s.
Adjacent suites zero-regression: discovery.test.ts 103/103, wfdir.test.ts 40/40,
programmatic-api.test.ts + env-vars.test.ts 1470/1470. Typecheck clean on both
packages/loop-extender/tsconfig.build.json and apps/tests/.
Closes the {string-spelling, identity} × {CLI, programmatic} × {explicit-cwd,
omitted-cwd, trailing-slash, lexical-..} × {Bash, JS/TS} symlink/spelling matrix
on the runtime-side, complementing the foundational T-SYM-01..05 block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… / wrong-type cluster — SPEC §5.1 / §9.1 / §9.3)
Test-only iteration — existing `discovery.ts` already conformed across
all 14 IDs:
- Bare `try/catch` around `statSync` at workflow layer (lines 83-87) and
script layer (108-113) silently skips broken (ENOENT) and cyclic
(ELOOP) symlink resolutions.
- `!isDirectory()` (line 91) / `!isFile()` (line 114) filters silently
reject resolved-wrong-type targets (workflow-layer rejects files;
script-layer rejects directories).
- Name validation at lines 137 / 150-158 is structurally unreachable
for entries that fail stat or type checks — so an invalid alias on
a skipped entry is never surfaced as a name-restriction violation,
even in `run` mode where name violations would be FATAL per §5.4.
- `run()` and `runPromise()` call the same `discoverScripts()` and
surface standard "Workflow not found" / "Script not found" errors
for skipped entries via `run.ts:766-769` / `785-789`.
14 unique IDs added to `apps/tests/tests/e2e/discovery.test.ts` after
the foundational T-DISC-40a..40i (resolved-success and resolved-wrong-
name cases):
CLI surfaces (T-DISC-40j..40q, 8 IDs):
- T-DISC-40j: workflow-layer symlink → regular file
- T-DISC-40k: script-layer symlink → directory
- T-DISC-40l: broken workflow symlink
- T-DISC-40m: cyclic workflow symlinks
- T-DISC-40n: invalid-named broken/cyclic workflow symlink
(parameterized: broken + cyclic)
- T-DISC-40o: broken script symlink
- T-DISC-40p: cyclic script symlinks
- T-DISC-40q: invalid-named broken/cyclic script symlink
(parameterized: broken + cyclic)
Programmatic API counterparts (T-DISC-40r..40w, 6 IDs):
- T-DISC-40r/40s: 40l/40m × {runPromise, run} × runtimes
- T-DISC-40t: 40n × surfaces × broken/cyclic × runtimes
- T-DISC-40u/40v: 40o/40p × surfaces × runtimes
- T-DISC-40w: 40q × surfaces × broken/cyclic × runtimes
Verification:
- discovery.test.ts: 103 → 145 passing (42 new vitest invocations) in 14.98s
- symlink.test.ts + programmatic-api.test.ts: 1370 passed + 4 skipped
(1374 total, zero regressions) in 219.48s
- Typecheck clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…G-20/21/22/23/24/27/28/31 The `run` subcommand previously installed SIGINT/SIGTERM handlers only after all pre-iteration steps (discovery, env-file load, target resolution, the `-n 0` short-circuit) completed. As a consequence, signals delivered during the pre-iteration window were either uncaught (default POSIX termination) or lost the precedence contest with whichever non-signal pre-iteration failure had already begun surfacing. This change refactors the run subcommand so handlers are installed immediately after the no-target usage check and BEFORE every pre-iteration step. Each pre-iteration error site (discovery error, ensureLoopxPackageJson region, global / local env-file load, target parse error, missing workflow, missing default `index`, missing script in workflow, the `-n 0` short-circuit, and a final guard before `runLoop` starts) now starts with `if (receivedSignal) exitWithSignal()` so the signal exit code (`128 + N`) displaces the failure error per SPEC §7.3. Adds the test-only seam `LOOPX_TEST_PREITERATION_SENTINEL` declared in TEST-SPEC §1.4: when `NODE_ENV=test` AND `LOOPX_TEST_PREITERATION_SENTINEL` is set, loopx writes `LOOPX_PREITERATION_READY\n` to stderr immediately after handler install and then sleeps 300ms (bounded harness window) before any pre-iteration step. This lets the test harness deterministically synchronize signal delivery into the pre-iteration window without the slow-pre-iteration fallback fixture or `@flaky-retry`. Production behavior is unchanged. Adds 9 tests (8 distinct T-SIG IDs) in `apps/tests/tests/e2e/signals.test.ts`: T-SIG-20 (missing -e env), T-SIG-21 (missing workflow), T-SIG-22 (invalid target), T-SIG-23 (tmpdir creation failure, root-skipped), T-SIG-24 (missing .loopx/), T-SIG-27 (missing script in workflow), T-SIG-28 (missing default index for bare target), and T-SIG-31 with two variants (SIGINT/SIGTERM on a fully valid pre-iteration — no failure injected, asserts no child spawn and no `loopx-*` directory leftover under the test-isolated TMPDIR parent). Verification: `npx vitest run --project signals` — 18/18 pass deterministically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…recedence: tier-1 silent override at inherited-env and local env-file tiers — SPEC §7.4 / §8.3 / §13)
Test-only iteration. The single source of truth for tier-1 silent
override of LOOPX_TMPDIR is the `scriptEnv` construction at
`packages/loop-extender/src/execution.ts:179-203`: the
`LOOPX_TMPDIR: loopxTmpdir` assignment at line 185 is placed AFTER
the `...env` spread at line 180, so any user-supplied LOOPX_TMPDIR
from RunOptions.env, env-file (`-e` local or global), or inherited
process env is unconditionally overwritten by the freshly-`mkdtemp`'d
run-scoped path. No diagnostic is emitted from this code path or
any other site that touches LOOPX_TMPDIR — the override is silent
per SPEC §13 / §8.3.
T-TMP-30 (inherited-env tier): spawns `loopx run -n 1 ralph` with
`env: { TMPDIR: <test-isolated-parent>, LOOPX_TMPDIR: "/tmp/fake-loopx-tmp" }`.
The bash fixture writes `$LOOPX_TMPDIR` to a marker, `stat`s the
path during execution (writes "exists-as-dir" / "missing"), and
emits `{"stop":true}`. Asserts: (a) marker is NOT
`/tmp/fake-loopx-tmp`, has `dirname` equal to the test-isolated
parent, and `basename` starts with `loopx-` — proving the real
injected tmpdir won; (b) stat marker is "exists-as-dir" — the
during-run stat is essential because SPEC §7.4 cleanup removes the
path on run completion, so a post-run stat would observe absence;
(c) `stderr` does NOT match
`/loopx_tmpdir.*(override|overrid|ignored|warning|notice)/i` —
the silent-override half of the SPEC §8.3 / §13 contract.
T-TMP-31 (local env-file `-e` tier): creates `local.env` containing
`LOOPX_TMPDIR=/tmp/fake-loopx-tmp`. Runs
`loopx run -e local.env -n 1 ralph` with
`env: { TMPDIR: <test-isolated-parent> }`. The bash fixture writes
`$LOOPX_TMPDIR` to a marker. Asserts: (a) marker is NOT
`/tmp/fake-loopx-tmp`, has `dirname` equal to the test-isolated
parent, and `basename` starts with `loopx-`; (b) `stderr` does NOT
match the same silent-override regex.
Both tests use the existing `setupTmpdirTest()` helper which
provides a `mkdtemp`'d isolated tmpdir parent registered for
`afterEach` cleanup, plus a `createTempProject()` that owns the
workflow tree. The override regex matches the shape used by
T-WFDIR-06 / T-WFDIR-07 (LOOPX_WORKFLOW_DIR counterpart at the
same two tiers). A buggy implementation that emitted
"user-supplied LOOPX_TMPDIR was overridden" to stderr would fail
(c) of T-TMP-30 and (b) of T-TMP-31 even if the marker assertions
passed.
2 unique IDs × 2 runtimes (Node + Bun) = 4 tests added to
`apps/tests/tests/e2e/tmpdir.test.ts` between T-TMP-29k and
T-TMP-32, inside the existing
`describe("TEST-SPEC §4.7 LOOPX_TMPDIR")` `forEachRuntime`
block.
tmpdir.test.ts: 569 → 573 tests, all 4 new tests passing in 170ms.
Adjacent suites zero-regression: wfdir + env-vars 176/176;
typecheck clean.
Closes the SPEC §8.3 precedence coverage for LOOPX_TMPDIR alongside
T-WFDIR-06 / T-WFDIR-07 (LOOPX_WORKFLOW_DIR) and T-ENV-21a
(LOOPX_PROJECT_ROOT) at the same tier-1 protocol-injection
contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… path → exit 1 — SPEC §12)
Test-only iteration — existing implementation already conformed.
parseTarget(':script') returns ok:false at target-validation.ts:53-57
(leading-colon → empty workflow branch); bin.ts:709-713 writes the
diagnostic to stderr and exits 1.
T-EXIT-17 is the non-signal counterpart to T-SIG-22 (signals.test.ts:534),
which exercises the same ':script' invalid-target shape on the
signal-wins precedence path. Setup mirrors T-SIG-22: a valid
.loopx/ralph/index.sh tree is created so discovery succeeds, ensuring
the rejection happens at the post-discovery target-resolution
checkpoint (matching SPEC §12's "rejected after discovery, at the
same point as a missing workflow or missing script" wording).
1 unique ID × 2 runtimes = 2 tests added to exit-codes.test.ts after
T-EXIT-16 inside the existing forEachRuntime block. Asserts exit 1,
non-empty stderr, /[Ii]nvalid target/ match, literal ":script"
substring (rules out missing-workflow rerouting), and stdout silence
(SPEC §7.1 / T-LOOP-46).
exit-codes.test.ts: 32 → 34 tests, all 34 passing.
Adjacent suites zero-regression: cli-grammar 58/58 (1 Bun-skipped),
cli-basics 338/338, signals 18/18. Typecheck clean.
fix_plan.md: new RESOLVED subsection at top; open-issue references
in P1 TDD-Gate delta / TDD-Gate audit §4.14 / P2 supplementary /
P3 inventory marked done; NEXT-UP recommendation refreshed to T-TYPE-08
(natural remaining half of the prior pairing). Unauthored test count
~403 → ~402.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-checks and exposes script-helper signatures — SPEC §6.4 / §6.5 / §9.4 / §9.5)
Test-only iteration — existing implementation already conformed.
- index.ts:3-4 already re-exports `output` / `input` as values.
- output-fn.ts:19 already declares `output(value: unknown): never` (`unknown`
parameter accepts every TEST-SPEC-enumerated argument tuple; `never` return
is permitted by TEST-SPEC's "void or never both conformant" clause).
- input-fn.ts:13 already declares `input(): Promise<string>` (zero-arg, exact
return type per SPEC §6.5).
1 typecheck-mode test added to apps/tests/tests/unit/types.test.ts after
T-TYPE-07. Asserts: (a) value imports compile + missing-module guard via
expectTypeOf().not.toBeAny() + .toBeFunction(); (b) input() zero-arg signature
+ ReturnType exactly equals Promise<string>; (c) output(...) callable with
[{result}], [{goto}], [{stop}], [number], [string] argument tuples — does NOT
pin a return-type symbol per TEST-SPEC.
types.test.ts: 7 → 8 tests (14 → 16 invocations counting both runtime and
typecheck-mode passes), all green; type errors: none. Adjacent suites
zero-regression: unit 143/143; loopx package tsc --noEmit clean; e2e
exit-codes 34/34. Closes the SPEC §9.4 / §9.5 type-surface gap for the
script-helper exports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ence final cluster: SIGINT-vs-discovery-time-sibling-validation-failure + SIGINT/SIGTERM-vs-unreadable-global-env-file — SPEC §7.3 / §5.1 / §5.4 / §8.1 / §7.1) Test-only iteration — existing implementation already conformed: - bin.ts:659 guard inside the discovery-error branch suppresses the fatal `Error:` write when receivedSignal is set (covers T-SIG-25 sibling-validation discovery failure). - bin.ts:679 guard inside the loadGlobalEnv() try/catch suppresses the fatal `Error:` write when receivedSignal is set (covers T-SIG-30 unreadable global env file mode 000). 2 unique IDs (T-SIG-25 single SIGINT-only test; T-SIG-30 parameterized over SIGINT/SIGTERM = 2 sub-cases) = 3 tests added to apps/tests/tests/e2e/signals.test.ts: - T-SIG-25 (between T-SIG-24 and T-SIG-27): valid `ralph` workflow + sibling `broken` workflow with invalid `-bad.sh` script name (the T-DISC-47a pattern). Asserts exit 130, stderr does not match /Error:.*invalid name/i, stderr does not contain literal `-bad`. - T-SIG-30 (between T-SIG-28 and T-SIG-31, parameterized over SIGINT/SIGTERM): inlined unreadable-global-env-file setup — mkdtemp xdgHome, mkdir <xdg>/loopx, write <xdg>/loopx/env, chmod 000. XDG_CONFIG_HOME injected ONLY into the runCLIWithSignal child env (NOT process.env, to avoid perturbing parallel test workers). Asserts exit 130/143, stderr does not match /Global env file is unreadable/, stderr does not match /EACCES/. it.skipIf(IS_ROOT) per TEST-SPEC §4.11 (root can read mode-000 files). Both tests use the existing LOOPX_TEST_PREITERATION_SENTINEL test seam from the prior §7.3 cluster (TEST-SPEC §1.4) for deterministic signal delivery into the pre-iteration window. signals.test.ts: 18 → 21 tests, all 21 passing in 16.73s. Adjacent suites zero-regression: discovery.test.ts 145/145 + env-vars 136/136 = 281/281; typecheck clean on both packages/loop-extender and apps/tests. Closes the §4.11 Signals × pre-iteration-signal-wins block other than the documented known gaps T-SIG-26 / T-SIG-29 / T-SIG-30b (each requires additional seams not yet declared). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e-iteration surface (T-TERM-01/01-run/03/05) Wire the new TEST-SPEC §1.4 child-exit-handler seam in loop.ts (after pinIterationFirstObserved / before iteration throw) to deterministically pin the child-exit terminal trigger as first-observed and pause for the bounded interval before the iteration error is dispatched. Used by T-TERM-01 (variant a) / T-TERM-01-run (variant a) / T-TERM-05 to race a second terminal trigger (abort or signal) into the post-observation window; per SPEC §7.2 the racing trigger does NOT displace the first-observed iteration outcome. Thread firstObservedRef through the CLI bin.ts run subcommand: the SIGINT/ SIGTERM signal handler pins firstObservedRef.trigger = "abort" (only when null — preserves first-signal-wins for receivedSignal); runLoop pins "iteration" via pinIterationFirstObserved before throwing iteration-level errors; the post-loop catch block now consults `firstObservedRef.trigger !== "iteration"` before exitWithSignal() so a SIGTERM arriving during the post-observation child-exit-handler pause does NOT shift the exit code from 1 to 143 (T-TERM-05). When the signal was first-observed, exit with the signal's code (preserves T-SIG-04 / T-SIG-07 / T-TMP-38 / T-TMP-38f / T-TERM-03 behavior). Tests: 6 unique IDs × 2 runtimes = 12 vitest invocations added to apps/tests/tests/e2e/tmpdir.test.ts after the existing T-TMP-38e block — T-TERM-01 (variant a + variant b on runPromise()), T-TERM-01-run (variant a + variant b on run()), T-TERM-03 (CLI residual-precedence on cleanup-time signal-vs-signal axis with vanilla cleanup path), T-TERM-05 (CLI residual- precedence on active-iteration child-exit-vs-signal axis with LOOPX_TEST_CLEANUP_FAULT=recursive-remove-fail). Variant b tests reuse the existing abort-listener seam + a `trap 'exit 1' SIGUSR1` fixture (mirrors T-TMP-38b2 / T-TMP-38b2-run pattern). All 12 new tests pass on Node and Bun in 38.20s. Adjacent suites zero-regression: signals (21/21), programmatic- api (1334/1334), execution + env-vars + wfdir + loop-state + discovery (514/514), unit + harness (158/158), cli + edge + exit + output + sub + symlink + version + delegation + module-resolution (978 + 5 skipped). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… first-observed-wins on the API surfaces — abort × consumer-cancellation (.return()/.throw()) and child-spawn-failure × abort residual axes on both run() and runPromise()) Adds 5 unique TEST-SPEC IDs (16 vitest invocations: 8 it() blocks × 2 runtimes) to apps/tests/tests/e2e/tmpdir.test.ts: - T-TERM-02 variants a/b — abort × consumer .return() race on run(), parameterized over both observation orders (abort-listener seam for abort-first; consumer-return-observed seam for .return()-first). - T-TERM-02c — abort-first × consumer .throw() race on run() (abort- listener seam); abort error wins, consumer-thrown "test-throw" neither chains nor displaces. - T-TERM-02d — consumer .throw()-first × abort race on run() (consumer- throw-observed seam); surfaced outcome NOT abort, recorded LOOPX_TMPDIR removed exactly once before settlement, ≤ 1 cleanup warning. - T-TERM-04 variants a/b — child-spawn-failure × abort race on runPromise(), parameterized over both observation orders (child- spawn-failure seam for spawn-fail-first; child-spawn-attempt seam for abort-first); NUL-in-RunOptions.env value drives the spawn rejection. - T-TERM-04-run variants a/b — same race on run() generator surface. Test-only iteration. The existing firstObservedRef shared trigger- tracking infrastructure already conformed: - run.ts:517 / :601 — wrapper.return / wrapper.throw `abortObservedFirst` branch SKIPS pin and consumer-*-observed seam pause when firstObservedRef.trigger is already "abort", so internalAc.abort() + inner gen.return() drive the active-child abort path and wrapper.next catch surfaces the abort error via the trigger==="abort" branch at :465-467. - run.ts:540 / :618 — consumer-return-observed / consumer-throw-observed seams fire only when consumer-cancellation pinned first-observed, pausing AFTER pinning + returnCalled set and BEFORE internalAc.abort()/inner gen.return() dispatch. - execution.ts:253 — child-spawn-attempt seam fires BEFORE spawn() is invoked (no recheck of signal.aborted post-pause, allowing the genuine race the seam is designed to expose). - execution.ts:269-270 / :346-348 — sync + async spawn-failure paths pin firstObservedRef.trigger = "iteration" BEFORE the child-spawn- failure seam pause at :276 / :349, so a racing abort during the pause cannot displace the spawn-failure outcome. Coordination with sibling tests: - T-TERM-02 cluster shares fixtures (long-lived "ready" script) with T-TMP-38a/38a2/38c/38c2 but uses abort-listener / consumer-*-observed seams instead of cleanup-start, pinning the OUTCOME axis on seams unreachable from cleanup-start without a fault. - T-TERM-04/04-run cover the OUTCOME axis on a vanilla cleanup path (no recursive-remove-fail fault); T-TMP-38e/38e-run cover the cleanup-cardinality axis on the same seam matrix with the fault. tmpdir.test.ts: 585 → 601 tests, all 16 new pass on Node 25.2.1 and Bun 1.3.11, full-file regression run 601/601 passing in ~5min. Type-check clean (`tsc --noEmit -p packages/loop-extender/ tsconfig.build.json`). Closes the SPEC §7.2 first-observed-wins residual contract on the abort × consumer-cancellation and child-spawn-failure × abort axes across both API surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…install` forwards to child process group, loopx exits 130/143 — SPEC §10.10 / §7.3)
Mixed implementation + test iteration. install.ts: added `InstallSignalContext`
interface (`receivedSignal()` getter + `setActiveNpmChild()` setter); threaded
through `InstallOptions` / `runAutoInstall` so the workflow loop checks
`receivedSignal()` at every iteration head, spawns `npm install` with
`detached: true`, registers/clears the active child, suppresses both the
per-workflow failure entry (when `code === "NPM_SIGNAL"` AND `receivedSignal !==
null`) and the aggregate failure report (when `receivedSignal !== null` at
end-of-pass).
bin.ts: install branch now installs SIGINT/SIGTERM handlers BEFORE calling
`installCommand`. The handler pins `installReceivedSignal` (only first wins),
and if `installActiveNpmChild` is set with a pid, forwards the same signal to
its process group via `process.kill(-pid, sig)` (falling back to
`child.kill(sig)`) and starts a 5-second SIGKILL escalation timer that kills
the process group via `process.kill(-pid, "SIGKILL")`. Setting
`setActiveNpmChild(null)` clears the SIGKILL timer. After `installCommand`
returns (in `finally`), handlers are removed and any pending SIGKILL timer is
cleared. If `installReceivedSignal` is non-null, exit with `128 + sigNum`.
Tests added (after T-INST-115, before T-INST-118): 2 unique IDs × 2 runtimes =
4 vitest invocations in `apps/tests/tests/e2e/install.test.ts`. Each spawns
`loopx install <git-source>` via `runCLIWithSignal` against `withFakeNpm({
sleepSeconds: 30, pidFile, logFile })`, waits for "ready" on stderr, reads the
shim PID from the pidFile, sends SIGINT/SIGTERM, and asserts: (a) loopx exits
130/143, (b) the shim process group is no longer running (`kill -0 <shim-pid>`
returns ESRCH), (c) the shim recorded exactly one invocation (the
EXIT-trap-driven log entry fires under signal termination), (d) stderr does not
contain "auto-install failures" (aggregate report suppressed under signal).
Verification: typecheck clean; build clean; new tests 4/4 pass on Node 25.2.1
+ Bun 1.3.11. install.test.ts 505/506 (only pre-existing T-INST-GLOBAL-01a
[Bun] fails — documented in fix_plan); signals 21/21; tmpdir 601/601;
programmatic-api + env-vars + loop-state + wfdir + execution + version-check
1859/1859; unit + harness 158/158. Zero regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m install` — multi-workflow no-further-processing guarantee — SPEC §10.10 / §7.3)
Test-only iteration extending the prior-iteration T-INST-116 / T-INST-116a
single-workflow pinning to the multi-workflow loop. SPEC §10.10 "Signals
during `npm install`" says "remaining committed workflows are not processed
(no further `.gitignore` synthesis or `npm install` invocations)". Existing
implementation already conforms via the head-of-iteration
`signalContext.receivedSignal()` guard at install.ts:504-506, which
short-circuits the workflow loop the moment SIGINT/SIGTERM is observed.
2 unique IDs × 2 runtimes = 4 vitest invocations added to install.test.ts
between T-INST-116a and T-INST-118. Multi-workflow `alpha`/`beta`/`gamma`
source via `startLocalGitServer`; `withFakeNpm({ sleepSeconds: 30 })` so
every invocation sleeps; `runCLIWithSignal` + `waitForStderr("ready")` to
synchronize on the first invocation regardless of auto-install order;
`sendSignal("SIGINT" | "SIGTERM")`. Order-independent assertions key on
count + cwd-derived first-processed workflow name: exit 130/143; exactly
one shim invocation; only the first-processed workflow's directory contains
a synthesized `.gitignore`; the other two do not; all three committed
workflow directories still exist on disk byte-for-byte from the source.
install.test.ts: 4/4 new tests pass on both Node and Bun; existing
T-INST-116/116a continue to pass; pre-existing T-INST-GLOBAL-01a [Bun]
failure is unchanged and unrelated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…GINT during `post-exit-first` no-active-child auto-install window — SPEC §10.10 / §7.3) Adds the LOOPX_TEST_AUTOINSTALL_PAUSE seam (TEST-SPEC §1.4) to install.ts so the no-active-child signal-window tests can deliver SIGINT/SIGTERM during the otherwise sub-millisecond gap between one workflow's npm child exit and the next workflow's safeguard lstat. The seam parses both ordinal (post-exit-first) and name-targeted (post-exit:<name>) forms; only the post-exit window is wired in this iteration. The seam writes a UTF-8 JSON marker file at LOOPX_TEST_AUTOINSTALL_PAUSE_MARKER (sync openSync / writeSync / fsyncSync / closeSync) before the bounded 5-second pause begins, then polls signalContext.receivedSignal every 50ms for early resume. The runAutoInstall per-workflow loop is refactored to track processed[] + npmChildExitCount via an indexed for loop + per-iteration IIFE so the seam can fire on the first observed npm child exit (success or non-zero alike). Tests: 3 unique IDs × 2 runtimes = 6 vitest invocations added to install.test.ts between T-INST-116b2 and T-INST-118. T-INST-116j covers SIGTERM × successful npm exit × completed node_modules/ side-effect preservation (assertion (g) — completed-marker survives across signal-termination per SPEC §10.10's "side effects completed before the signal observation remain on disk" clause). T-INST-116j2 covers SIGINT × non-zero npm exit × aggregate-report suppression on the npm-non-zero accumulator path (signal supersedes the npm-non-zero exit code; exit 130 not 1). T-INST-116j3 is the SIGTERM-axis parity for j2. install.test.ts: 510 → 516 tests, all 6 new pass on Node 25.2.1 and Bun 1.3.11. Adjacent suites zero-regression: signals 21/21, tmpdir 601/601, programmatic-api 1334/1334, unit+harness 158/158, loop-state+version-check+env-vars 400/400. Only pre-existing T-INST-GLOBAL-01a [Bun] failure remains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tween-workflows-after-first` no-active-child auto-install window — SPEC §10.10 / §7.3)
Mixed implementation + test iteration extending the existing TEST-SPEC §1.4
LOOPX_TEST_AUTOINSTALL_PAUSE seam to wire the `between-workflows-after-first`
ordinal window at the gap between one workflow's full processing (npm-child
exit + push) and the next workflow's safeguard begin.
Implementation in `packages/loop-extender/src/install.ts` (`runAutoInstall`):
added a between-workflows pause-seam dispatch block AFTER
`processed.push(workflowName)` and BEFORE the next iteration's head-of-loop
signal check. Fires on the first workflow-to-workflow transition
(`processed.length === 1`) for the ordinal form and on the matching workflow
name for the named form (`between-workflows:<name>`); both forms additionally
require `i + 1 < committed.length`. The marker payload reports
`processed = [first-completed]`, `current = upcoming-workflow`,
`remaining = workflows-after-upcoming` so the harness can read the
implementation-defined auto-install order from the marker rather than
hard-coding alpha/beta/gamma. The post-exit-first wiring (prior iteration)
at the same focal point — fires BEFORE push and on `npmChildExitCount === 1`
— was untouched.
Tests: 2 unique IDs × 2 runtimes = 4 vitest invocations added to
`apps/tests/tests/e2e/install.test.ts` immediately before the T-INST-116j
cluster comment block. Both use `withFakeNpm({ exitCode: 0, logFile })` (the
npm shim succeeds quickly; the pause seam creates the no-active-child
window), poll for `markerPath`, parse JSON, verify
`marker.window === "between-workflows-after-first"` and
`processed.length === 1`, send the signal, then verify exit code 130/143,
exactly one shim invocation against `first`, only `first`'s `.gitignore`
synthesized as `node_modules\n`, byte-for-byte preservation of all three
committed workflow files (`index.sh` + `package.json`), and no aggregate
failure report.
Validation: typecheck clean; install.test.ts 516 → 520 tests, 519/520 (only
pre-existing T-INST-GLOBAL-01a [Bun] failure remains, documented); adjacent
suites zero-regression — signals (21/21), tmpdir (601/601). The
T-INST-116h..116n2 no-active-child cluster has now closed two windows:
post-exit-first (prior iteration) and between-workflows-after-first (this
iteration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e-spawn-first` no-active-child auto-install window — SPEC §10.10 / §7.3)
Implementation in packages/loop-extender/src/install.ts:
- Added a `GitignoreStateAtPause` discriminated-union type plus a
`captureGitignoreStateAtPause(workflowDir)` helper that lstats
`<workflowDir>/.gitignore` and returns one of:
{exists: false} (ENOENT)
{exists: true, type: "regular", content: <utf-8>}
{exists: true, type: "symlink"|"directory"|"fifo"|"socket"|"other"}
The discriminator deterministically captures the post-safeguard on-disk
state for the marker payload's `gitignoreStateAtPause` field per
TEST-SPEC §1.4 contract for the `pre-spawn-first` / `pre-spawn:<name>`
window values.
- `AutoInstallPausePayload.gitignoreStateAtPause` field type tightened
from `unknown` to the new `GitignoreStateAtPause` union for type-safety.
- Added a `pre-spawn-first` / `pre-spawn:<name>` dispatch block in
`runAutoInstall` immediately after the `runGitignoreSafeguard` success
path and before the `spawn("npm", "install")` call. Fires when:
Ordinal: window === "pre-spawn-first" && processed.length === 0
Named: window === "pre-spawn" && pauseSpec.workflow === workflowName
After the seam returns, a `signalContext.receivedSignal()` check
returns from the IIFE WITHOUT spawning npm — the head-of-iteration
check on the next iteration then breaks the outer loop, satisfying
SPEC §10.10's "no further `npm install` children are started"
guarantee.
Tests: 2 unique IDs × 2 runtimes = 4 vitest invocations in
apps/tests/tests/e2e/install.test.ts immediately before the T-INST-116j
cluster comment block. Three-workflow source `alpha`/`beta`/`gamma`
(same shape as T-INST-116h fixture), `withFakeNpm({ exitCode: 0 })`.
Both tests poll for the marker, parse JSON, verify
`marker.window === "pre-spawn-first"` plus `marker.processed === []`,
extract `first = current` and `untouched = remaining`, read
`marker.gitignoreStateAtPause`, send the signal, then verify:
(a) exit code 130/143
(b) zero shim invocations (spawn preempted by signal observation)
(c) untouched workflows have no `.gitignore`
(d) byte-for-byte preservation of all three committed workflow files
(e) no aggregate failure report
(f) post-exit `.loopx/<first>/.gitignore` matches `gitignoreStateAtPause`
byte-for-byte across all sub-cases including the absent → absent
case — pinning both halves of SPEC §10.10's "side effects completed
before the signal observation remain on disk" rule (existing-state
preservation AND the load-bearing negative form "side effects that
had not begun do not start after the signal observation").
Validation: typecheck clean; install.test.ts 520 → 524 tests, 523/524
(only pre-existing T-INST-GLOBAL-01a [Bun] failure remains, documented);
T-INST-116 cluster regression check 22/22; signals 21/21; tmpdir
601/601.
Closes the third of six no-active-child sub-windows in the
T-INST-116h..116n2 cluster (post-exit-first done two iterations ago,
between-workflows-after-first done last iteration). Remaining
no-active-child windows: T-INST-116k/116k2 (`post-safeguard-failure-first`),
T-INST-116l/116l2 (`post-aggregate-report` carve-out), T-INST-116m/116m2
(`post-spawn-failure-first`), T-INST-116n/116n2 (`before-first-workflow`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st-safeguard-failure-first` no-active-child auto-install window — SPEC §10.10 / §7.3)
Wires the fourth no-active-child window in TEST-SPEC §1.4's
`LOOPX_TEST_AUTOINSTALL_PAUSE` seam: the gap between a `.gitignore`
safeguard failure being recorded into the `failures` accumulator and
the next workflow's iteration beginning. The load-bearing assertion is
that signal termination still suppresses the final auto-install
aggregate failure report **even when `failures` already accumulated a
safeguard-failure entry** — a buggy implementation that handled the
suppression correctly with empty `failures` (T-INST-116h/116i/116j) but
failed to suppress when `failures` was non-empty would pass the prior
tests and fail this one.
Implementation in `packages/loop-extender/src/install.ts`:
- Extended `AutoInstallFault` and `getAutoInstallFault()` to recognize a
new fault seam `LOOPX_TEST_AUTOINSTALL_FAULT=gitignore-replace-with-fifo:<names>`.
The seam mkfifos at the workflow's `.gitignore` path immediately
before the safeguard `lstat`; the existing non-regular-file branch
then organically returns `{ ok: false, reason: ".gitignore exists
but is not a regular file (FIFO)" }`. NODE_ENV=test gating preserved.
- Added a `safeguardFailureCount` counter alongside the existing
`processed` / `npmChildExitCount` accumulators, incremented after
`failures.push(...)` on the safeguard-failure branch.
- Added a post-safeguard-failure pause-seam dispatch block inside the
IIFE's `if (!gitignoreOk.ok)` branch between `failures.push(...)`
and `return;`, gated on `safeguardFailureCount === 1` (ordinal
`post-safeguard-failure-first`) or workflow-name match (named
`post-safeguard-failure:<name>`). Marker payload: `current = workflowName`,
`processed = [...processed]` (does NOT include the failed workflow),
`remaining = committed.slice(i + 1)`. After the pause, the IIFE
returns; the next iteration's head-of-loop signal check breaks the
outer loop; the end-of-pass guard returns 0 before emitting the
aggregate report when `signalContext.receivedSignal() !== null`.
Tests: 2 unique IDs × 2 runtimes = 4 vitest invocations added to
`apps/tests/tests/e2e/install.test.ts` between T-INST-116j3 and
T-INST-118. Both use a three-workflow `alpha`/`beta`/`gamma` source via
`startLocalGitServer`, `withFakeNpm({ exitCode: 0, logFile })`,
`LOOPX_TEST_AUTOINSTALL_PAUSE=post-safeguard-failure-first`, and
`LOOPX_TEST_AUTOINSTALL_FAULT=gitignore-replace-with-fifo:alpha,beta,gamma`
(FAULT applied to all three workflows so whichever the implementation
processes first becomes `failed` — order-independence guarantee).
Order-independent assertions: (a) exit 130/143, (b) zero shim
invocations, (c) `.loopx/<failed>/.gitignore` is still a FIFO, (d)
`notReached` workflows have no `.gitignore`, (e) all three committed
workflow directories preserved byte-for-byte (no rollback), (f) **no
aggregate failure report on stderr** despite `failures` already
accumulating `failed`'s entry — the load-bearing assertion.
Validation: typecheck clean; install.test.ts 528 invocations,
527/528 pass (only pre-existing T-INST-GLOBAL-01a [Bun]
unrelated to ADR-0004); adjacent suites zero-regression — signals
21/21, tmpdir 601/601.
Closes the third of six no-active-child sub-windows in the
T-INST-116*–T-INST-116n2 cluster (post-exit-first /
between-workflows-after-first / pre-spawn-first done in earlier
iterations; post-safeguard-failure-first done this iteration;
post-spawn-failure-first / before-first-workflow / post-aggregate-report
remain).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st-spawn-failure-first` no-active-child auto-install window — SPEC §10.10 / §7.3)
Mixed implementation + test iteration extending the existing TEST-SPEC §1.4
LOOPX_TEST_AUTOINSTALL_PAUSE seam to the fifth no-active-child window: the
gap between an `npm install` spawn failure being recorded into the
`failures` accumulator and the next workflow's iteration beginning. The
load-bearing assertion is that signal termination still suppresses the
final auto-install aggregate failure report even when `failures` already
accumulated a spawn-failure entry — a buggy implementation that handled
the suppression correctly with empty `failures` (T-INST-116h/116i/116j) or
with a safeguard-failure entry (T-INST-116k) but failed to suppress on the
spawn-failure accumulator state would pass the prior tests and fail this
one.
Implementation in `packages/loop-extender/src/install.ts`:
- Extended `AutoInstallFault` and `getAutoInstallFault()` to recognize a
new fault seam `LOOPX_TEST_AUTOINSTALL_FAULT=npm-spawn-fail:<names>`
(intercepts `spawn("npm", ...)` for the named workflow with an
ENOENT-shaped reject before any real spawn occurs).
- Added a `spawnFailureCount` counter alongside the existing `processed`
/ `npmChildExitCount` / `safeguardFailureCount` accumulators.
- Added a post-spawn-failure pause-seam dispatch block inside the IIFE's
`e.code === "ENOENT"` catch branch between `failures.push(...)` /
`spawnFailureCount++` and the next branch, gated on
`spawnFailureCount === 1` (ordinal) or workflow-name match (named).
- Marker payload: `current = workflowName`, `processed = [...processed]`
snapshot at focal point (does NOT include the failed workflow),
`remaining = committed.slice(i + 1)`.
Tests in `apps/tests/tests/e2e/install.test.ts`: 2 unique IDs × 2
runtimes = 4 vitest invocations between T-INST-116k2 and T-INST-118.
Both use `withFakeNpm({ exitCode: 0, logFile })` +
`LOOPX_TEST_AUTOINSTALL_FAULT=npm-spawn-fail:alpha,beta,gamma` (FAULT
applied to all three workflows so whichever the implementation processes
first becomes `failed` — order-independence guarantee).
Order-independent assertions: (a) exit 130/143, (b) zero shim invocations
(FAULT seam intercepts spawn before real call AND notReached preempted by
signal), (c) `.loopx/<failed>/.gitignore` synthesized as a regular file
with `node_modules\n` (safeguard ran successfully BEFORE spawn was
attempted), (d) `notReached` workflows have no `.gitignore` on disk, (e)
all three committed workflow directories preserved byte-for-byte (no
rollback), (f) **no aggregate failure report on stderr** despite
`failures` already accumulating `failed`'s spawn-failure entry — the
load-bearing assertion.
Validation: typecheck clean; targeted `T-INST-116m` 4/4 pass on Node
25.2.1 + Bun 1.3.11; full T-INST-116* regression scope 30/30 pass; full
install.test.ts 531/532 (only pre-existing T-INST-GLOBAL-01a [Bun]
failure unrelated to ADR-0004); signals 21/21; tmpdir.test.ts 601/601.
Closes the fifth of seven no-active-child sub-windows in the
T-INST-116h..116n2 cluster (post-exit-first / between-workflows-after-first
/ pre-spawn-first / post-safeguard-failure-first done in earlier
iterations; post-spawn-failure-first done this iteration;
before-first-workflow / post-aggregate-report remain).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…NT/SIGTERM during active `npm install` child — SPEC §10.10 / §7.3) Test-only iteration closing the active-child cluster of SPEC §10.10 "Signals during `npm install`": - T-INST-116c: SIGTERM-trap → 5-second SPEC §7.3 grace → SIGKILL escalation. Asserts elapsed ∈ [4s, 10s], shim PID dead after the window, loopx exit 143, no aggregate-failure-report. - T-INST-116f: SIGINT counterpart to 116c (5-second grace + SIGKILL on SIGINT, exit 130). - T-INST-116d: SIGINT during `npm install` does not clean partial `node_modules/` state on disk (no rm by loopx). - T-INST-116d2: SIGTERM-axis parity for 116d. - T-INST-116e: SIGTERM forwarded to the npm child's process group (grandchild also killed via `process.kill(-pid, sig)`). - T-INST-116g: SIGINT counterpart to 116e (process-group forwarding on SIGINT). Existing implementation already conformed: bin.ts:603-628 (forward to process group + 5s SIGKILL timer), install.ts:983 (`detached: true` makes the npm child a process-group leader), and the implicit no-cleanup-on-signal contract. Helper changes in apps/tests/tests/helpers/fake-npm.ts (required to make the SPEC contracts observable): - Survival loop under `trapSignals`. Replaced single `sleep $SLEEP_FOR` with `while true; do sleep 1; done` so bash genuinely outlasts the 5-second grace window when ignoring TERM/INT — a single sleep would die from the process-group signal even with `trap '' TERM`, letting bash fall through to `exit` and short-circuiting the grace window. Same survival pattern as the runtime-script T-TMP-18a/18b tests. - `( trap - INT QUIT; exec sleep 3600 )` for `spawnGrandchild`. Bash's man page: "asynchronous commands ignore SIGINT and SIGQUIT in addition to these inherited dispositions" — non-interactive bash auto-sets SIGINT/SIGQUIT to SIG_IGN on `&`-async children, masking process-group SIGINT forwarding from the test perspective. The subshell resets the trap to default before exec so the grandchild responds to SIGINT and the load-bearing 116g assertion is observable. Validation: 12/12 new vitest invocations pass on Node 25.2.1 + Bun 1.3.11. T-INST-116 series: 42/42 (was 30, now 42) in 34.6s. T-INST-1[12]x cluster: 132/132. Full install.test.ts: 543/544 (the only failure is the pre-existing T-INST-GLOBAL-01a [Bun] flake documented in fix_plan.md "Pre-existing failures (not ADR-0004)"). T-SIG-05 still passes. SIGKILL bypasses the shim's bash EXIT trap, so 116c/116f cannot assert on `fake.readInvocations()` (the JSON log is empty even though the shim certainly ran — its PID was read from `pidFile` and "ready" was observed on stderr). The `void fake;` in those two tests is intentional documentation of the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fore-first-workflow` no-active-child auto-install window — SPEC §10.10 / §7.3) Mixed implementation + test iteration extending TEST-SPEC §1.4 LOOPX_TEST_AUTOINSTALL_PAUSE seam to a sixth no-active-child window covering the gap between auto-install pass entry and the first workflow's iteration body beginning. Implementation (packages/loop-extender/src/install.ts): - Added `before-first-workflow` pause-seam dispatch block at the very top of the per-workflow loop in `runAutoInstall`, after the `spawnFailureCount` accumulator declaration and before the `for (let i = 0; i < committed.length; i++)` opens. - Gating: `pauseSpec.kind === "ordinal" && pauseSpec.window === "before-first-workflow" && committed.length > 0`. Ordinal-only — no named form (the very-top-of-pass window has no per-workflow identity). - Marker payload: `current = committed[0]`, `processed = []`, `remaining = committed.slice(1)`. No `gitignoreStateAtPause` or `activeChildPid` field — neither concept applies in this window. Tests (apps/tests/tests/e2e/install.test.ts): - T-INST-116n (SIGINT) and T-INST-116n2 (SIGTERM) added between T-INST-116m2 and T-INST-118 — 2 unique IDs × 2 runtimes = 4 vitest invocations. - 3-workflow source alpha/beta/gamma via startLocalGitServer; no FAULT (the simplest fixture in the cluster). Order-independent assertions: exit 130/143; ZERO shim invocations; NO .gitignore on disk for any workflow; all three committed workflow directories preserved byte-for-byte; no aggregate failure report on stderr. - Load-bearing observation: a buggy implementation that wired no-active-child signal handling only into the per-workflow loop body (e.g., one that checks for receivedSignal before each workflow's safeguard but skips the check on the very first iteration's pre-pass setup) would pass T-INST-116h/i/j/k/m and fail this test. Validation: - typecheck clean - build clean - T-INST-116n run: 4/4 pass - T-INST-116* regression: 46/46 pass (was 42) - install.test.ts: 547/548 (only pre-existing T-INST-GLOBAL-01a [Bun] failure remains, unrelated to ADR-0004) - adjacent suites zero-regression: signals 21/21, tmpdir 601/601 Closes the SIXTH of seven no-active-child sub-windows in the T-INST-116h..116n2 cluster; only `post-aggregate-report` (T-INST-116l/116l2) remains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st-aggregate-report` no-active-child auto-install window — SPEC §10.10 / §7.3)
Closes the SEVENTH and final no-active-child sub-window in the T-INST-116
cluster: the gap between the auto-install aggregate failure report being
emitted to stderr and loopx's process exit. This pins the COMPLEMENTARY HALF
of SPEC §10.10's signal-termination clause — where T-INST-116k covers the
SUPPRESSION half (failure recorded + report not yet emitted + signal observed
→ report suppressed), T-INST-116l covers the ALREADY-EMITTED CARVE-OUT half
(report emitted + signal observed → report preserved on stderr, NOT
retroactively suppressed).
Implementation in `packages/loop-extender/src/install.ts`:
- Switched the aggregate-report writes inside `if (failures.length > 0)`
from `process.stderr.write(...)` to `writeSync(2, ...)` so the bytes
reach the kernel pipe synchronously before `pauseAutoInstallSeam`'s
fsync'd marker write. POSIX `process.stderr.write` for a pipe is async —
it queues the chunk in libuv's user-space buffer, leaving the data
behind libuv's marker fsync and at risk of truncation on the subsequent
`process.exit(128+sig)`.
- Added a `post-aggregate-report` pause-seam dispatch block between the
`writeSync` calls and `return 1;`, gated on `pauseSpec.kind === "ordinal"
&& pauseSpec.window === "post-aggregate-report"`. Marker payload:
`current = null` (per-workflow focal-position fields do not apply at
this window — all per-workflow processing has reached terminal state by
the time the report is emitted), `processed = [...processed]`,
`remaining = []`. Ordinal-only — no named form per TEST-SPEC §1.4.
- Added a post-pause `if (signalContext && signalContext.receivedSignal()
!== null) { return 0; }` guard so `installCommand` does NOT call
`process.exit(1)` when the signal arrived during the seam's pause —
`bin.ts`'s outer install signal handler observes
`installReceivedSignal !== null` and exits with `128 + signum`,
preserving the report on stderr (the carve-out half of the SPEC §10.10
clause).
- Updated doc comments on `runAutoInstall` and `InstallSignalContext` to
reflect that both halves of the clause are now meaningful.
Tests: 2 unique IDs × 2 runtimes = 4 vitest invocations added between
T-INST-116n2 and T-INST-118; both use 3-workflow `alpha`/`beta`/`gamma`
source + `withFakeNpm({ exitCode: 0 })` +
`LOOPX_TEST_AUTOINSTALL_PAUSE=post-aggregate-report` +
`LOOPX_TEST_AUTOINSTALL_FAULT=gitignore-replace-with-fifo:alpha,beta,gamma`.
Marker-time assertion `processed.length === 3` is load-bearing — pins
that the seam fires only after every qualifying workflow has been
processed. Post-exit assertions: (a) exit 130/143, (b) aggregate report
PRESENT on stderr (load-bearing carve-out assertion), (c) exactly ONE
copy of the report header, (d) committed files preserved byte-for-byte,
(e) `processed` set-equals {alpha,beta,gamma}, (f) FIFOs preserved,
(g) zero shim invocations.
Validation: typecheck clean; build clean; 4/4 pass on Node + Bun in 536ms;
full T-INST-116* regression: 50/50 pass (was 46, now 50); full
install.test.ts: 551/552 (only pre-existing T-INST-GLOBAL-01a [Bun]);
adjacent suites zero-regression: signals 21/21, tmpdir 601/601.
Together with T-INST-116k/116k2 this closes both halves of SPEC §10.10's
signal-termination clause for the aggregate-report path. The SPEC §10.10
no-active-child auto-install signal-window cluster
(T-INST-116h/116h2/116i/116i2/116j/116j2/116j3/116k/116k2/116l/116l2/
116m/116m2/116n/116n2) is now FULLY CLOSED.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ive `npm install` child × prior workflow's failure recorded — SPEC §10.10 / §7.3)
Closes the LAST gap in the SPEC §10.10 / §7.3 T-INST-116 cluster: the
active-child × prior-failure-suppression sentence (added this cycle
resolving P-0004-05). When SIGINT/SIGTERM arrives while an `npm install`
child is active for a workflow whose processing began after a prior
workflow's auto-install failure has been recorded into the aggregate
accumulator, signal termination must (a) terminate the active child via
process-group forwarding, (b) suppress the not-yet-emitted aggregate
failure report, and (c) preserve all committed workflow files on disk per
the no-rollback rule.
install.ts:
- Split the `npm install` spawn-and-wait across two awaits to allow the
new `child-active-after-failure` pause-seam dispatch to fire AFTER
spawn (with a live child) but BEFORE the spawn-exit promise is awaited.
- Added the seam dispatch block (gated on `failures.length >= 1` so the
first workflow's active-child window doesn't match — that's covered by
T-INST-116/116a). Marker payload carries `activeChildPid = child.pid`
so the harness can verify post-exit termination via `kill -0 <pid>`.
- Attached a noop `.catch(() => {})` to `spawnExitPromise` immediately
after construction. Without this, when the bash shim dies during the
seam pause (signal-induced), the rejection lands BEFORE
`await spawnExitPromise` attaches its handler, and Node 25's default
fatal-unhandled-rejection behavior crashes the process before the
catch block sees the rejection. The await still throws normally.
apps/tests/tests/helpers/fake-npm.ts:
- Restructured the bash shim to install `trap __finalize EXIT` BEFORE
the slow per-invocation setup (printenv enumeration + python3 calls,
~200-400ms). All trap-referenced variables are pre-initialized to safe
defaults (`ARGV_JSON="[]"`, `ENV_JSON="{}"`, `CWD_JSON="\"\""`, etc.)
so the trap fires safely under `set -u` even if setup hasn't completed.
- Switched ARGV_JSON / ENV_JSON building to atomic local-variable updates
(`__argv_local` / `__env_local`) followed by single-statement assignment
to the trap-referenced globals — prevents partial-JSON state in the log
if SIGINT interrupts mid-loop.
- T-INST-116o relies on this restructuring: SIGINT during the second
workflow's shim setup window must still produce a valid log entry for
that invocation; the previous trap install point would terminate bash
before the trap was set, leaving the log with only one entry instead
of two.
apps/tests/tests/e2e/install.test.ts:
- Added T-INST-116o (SIGINT) and T-INST-116o2 (SIGTERM) tests between
T-INST-116l2 and T-INST-118. Two-workflow source (alpha, beta) with
`exitCodeByWorkflow: { alpha: 1, beta: 1 }`,
`sleepByWorkflow: { alpha: 30, beta: 30 }` per TEST-SPEC for
order-independence. Both use `{ timeout: 90_000, retry: 2 }`.
AGENT.md updated with notes on the new shim trap-early/atomic-update
pattern and the noop-`.catch` requirement for Node 25.
Validation: typecheck clean; build clean; T-INST-116o tests 4/4 pass on
Node 25.2.1 + Bun 1.3.11; full T-INST-116*: 54/54 pass (was 50, +4);
install.test.ts: 555/556 (only pre-existing T-INST-GLOBAL-01a [Bun]
failure, unrelated); signals 21/21, tmpdir 601/601, unit + harness
158/158, zero regressions across all suites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ST-116o) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anch — SPEC §3.2) Add `lstat`-based dispatch in `version-check.ts` so non-regular entries (directory / FIFO / socket / symlink / etc.) at `.loopx/<workflow>/package.json` emit a dedicated SPEC §3.2 "is not a regular file" warning instead of falling through to the misleading "permission denied" path produced by `readFileSync`'s `EISDIR` error. Symlinks at the path are not followed (per SPEC §3.2 clause). - New `non-regular` discriminant on `VersionCheckResult` + `formatWarning` case. - Install.ts §10.6 preflight switch case mirrors the runtime warning text. - §10.10 auto-install path's pre-`lstat` (install.ts:836) already covered. Test: T-VER-28 in `version-check.test.ts` (directory variant — `.loopx/ralph/package.json/` becomes a directory containing a `README` placeholder; asserts exit 0, exactly one non-regular warning for `ralph`, marker file written, directory entry preserved unchanged with placeholder content intact). New test-helper predicates: `hasNonRegularWarning` / `countNonRegularWarnings`; `hasAnyPackageJsonWarning` extended to keep "no warning" assertions symmetric. Opens up the entire SPEC §3.2 non-regular-`package.json` warning branch (T-VER-28a..28e + 26d/27d + 28f..28x + 28y..28ai2) for follow-on iterations without further implementation changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…{local -e / global} × {CLI / runPromise() / run()} × {maxIter:1 spawn-failure / maxIter:0 silent-survive} — SPEC §7.2 / §7.4 / §8.1 / §8.2 / §9.1 / §9.2 / §9.3 / §9.5)
Test-only iteration — existing implementation already conformed across all
12 IDs. Closes the SPEC §7.2 / §9.5 "the runtime does not distinguish
tiers" coverage matrix on the env-file tiers (local -e / envFile and
global <xdg>/loopx/env) on all three run surfaces (CLI / runPromise() /
run()), matching the corresponding RunOptions.env tier coverage in
T-API-57/57a/57d/57e/57f/57f2/57h/57i.
12 unique IDs × 2 runtimes = 24 vitest invocations added to
apps/tests/tests/e2e/env-vars.test.ts in a new
`describe("SPEC: Env File NUL in Value -> Spawn Failure", …)` block
between T-ENV-25c and the existing T-ENV-28 block. Imports extended to
include `readdirSync` and `withGlobalEnvRawContent`.
Coverage matrix:
Tier | maxIter | CLI | runPromise() | run() generator
------------|---------|-------------|---------------|-----------------
local -e | 1 | T-ENV-26 | T-ENV-26a | T-ENV-26f
local -e | 0 | T-ENV-26b | T-ENV-26c | T-ENV-26g
global | 1 | T-ENV-27 | T-ENV-27a | T-ENV-27d
global | 0 | T-ENV-27b | T-ENV-27c | T-ENV-27e
Implementation conformance (no source changes needed):
- parsers/parse-env.ts lines 46-65 preserve NUL in values (no validation
or filtering).
- execution.ts:257 passes the merged scriptEnv to child_process.spawn
which raises ERR_INVALID_ARG_VALUE ("must be a string without null
bytes").
- loop.ts:281-291's try { … } finally { await cleanupTmpdir(…); } runs
cleanup BEFORE the spawn-failure error propagates to run() /
runPromise() callers, satisfying SPEC §9.3's "cleanup ordering is
observable" clause across both programmatic surfaces. CLI bin.ts
exit-1 path same-text parity.
- Under -n 0 / maxIterations:0, env-files are read and parsed
(validating readability + key shape — already-covered by
T-ENV-25b/25c/T-CLI-22f), but the runtime's environment-building step
never executes, so the NUL value silently survives the (skipped)
spawn step.
Validation:
- typecheck clean (both packages).
- npm run build clean (turbo cache hit).
- T-ENV-26 cluster: 12/12 pass on Node 25.2.1 + Bun 1.3.11.
- T-ENV-27 cluster: 12/12 pass.
- env-vars.test.ts full file: 160/160 pass (was 136).
- Full e2e regression: only the pre-existing T-INST-GLOBAL-01a [Bun]
failure remains (documented as unrelated to ADR-0004).
T-ENV-26d / T-ENV-26e (CLI in-loop discovered-script removed/renamed
cleanup-trigger — distinct fixture pattern, not NUL-byte related) are
deferred to a future iteration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.