Skip to content

test(devx): give the ref'd-timer instrument a home every package can reach, and gate the raw probe - #10905

Draft
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-10783-refd-timer-probe-and-gate
Draft

test(devx): give the ref'd-timer instrument a home every package can reach, and gate the raw probe#10905
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-10783-refd-timer-probe-and-gate

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes#10783
Closes#10785

The class — a process-global getActiveResourcesInfo() timer count scored with toBe across an await — has been found five times over four cards. Twice the first signal was a shard-only intermittent red whose message names a timer count rather than the change that caused it: once in CI (expected 2 to be 4), once in the merge queue, where it stalls every lane at once. This PR converts the fifth site, closes the class with a gate, and converts the one leg whose earlier repair could still hide a real leak.

The home decision, and the cost accepted

This is the load-bearing part, so it leads. @objectstack/service-automation could not reach packages/core/src/refd-timer-probe.testkit.ts at all — @objectstack/core exports exactly . and ./logger. The three candidate homes were measured, not argued:

homeverdict
test-only relative import into packages/core/src, declared in CROSS_PACKAGE_TEST_INPUTSrejected — measured TS6059
subpath on @objectstack/core's exportsrejected on principle
private, test-only workspace package, bare specifierchosen

Why the relative import lost. I built it first: the declaration and the turbo input glob both landed, check:cross-package-test-inputs went from OK: 13 package(s) to OK: 14 package(s), and both suites were green. Then tsc -p packages/services/service-automation/tsconfig.json:

src/engine.test.ts(19,51): error TS6059: File 'packages/core/src/refd-timer-probe.testkit.ts'
is not under 'rootDir' 'packages/services/service-automation/src'.

That package's tests are in its tsc program (its DEBT entry says so and --listFiles confirms it), so the count goes 3 → 4. check-type-check-coverage.mjs counts TS6059 for a package's own config deliberately — the asymmetry is documented at TSC_ROOTDIR_DIAGNOSTIC — and its own self-test pins “a count that grew by one is red too — no tolerance band”, with the raise marked ⛔ MAINTAINER-ONLY. So this route's only exit is a maintainer-only ratchet weakening. It is also not a quirk of one manifest: most packages here set rootDir: src, so it would have been a trap for the next adopter too.

Why the exports subpath lost. It would put a test helper on a runtime package's public npm surface, under semver, for every consumer of the kernel — capability expansion with no external pull, against the startup scope-discipline principle. It would also need a tsup entry, contradicting the instrument's own documented property that tsup never bundles it into dist.

⚠️#10785 predicted the api-surface ratchet "would rightly notice" this. I checked, and that prediction is wrongapi-surface/ is @objectstack/spec-scoped (packages/spec/api-surface/*.json, gated by pnpm --filter @objectstack/spec run check:api-surface), and @objectstack/core has no entry there. That makes the option worse, not better: the cost would have been paid silently. Recorded here so the next reader does not inherit the wrong reassurance.

What was chosen, and what it costs.packages/qa/refd-timer-testkitprivate: true, no build, exports straight at src, consumed as a devDependency. Measured: a bare specifier resolved through node_modules is exempt from rootDir (verified on a scratch fixture with --noEmitand with emit), so it is reachable from any consumer whatever its own build config says. service-automation's debt is back to exactly 3, its frozen value.

The cost accepted is a new workspace package — a manifest, a tsconfig, and one more row in the type-check-coverage census (64/77 → 65/78, arriving covered, debt unchanged at 436). The instrument also moves out of packages/core, so four core suites change one import line each.

The one thing this had to not break is turbo's Layer B, because a bare specifier is deliberately invisible to check:cross-package-test-inputs. Measured rather than assumed, on turbo run test --dry=json: @objectstack/refd-timer-testkit#build is a real graph node with inputs: ["package.json","src/index.ts","tsconfig.json"], and editing the instrument moves @objectstack/service-automation#test's hash ee8253e9cb374faa → 77680cb1d4bdd2d4. No hand-written input glob needed; the dependency edge carries it.

⛔ Not considered: forking a second copy into service-automation. Both cards turn it down, and the gate below would red on it anyway.

1. The fifth site (#10783)

packages/services/service-automation/src/engine.test.ts, at :2012–:2025 on the base commit — re-derived, matching the card. A local refdTimers() sampled either side of await engine.execute('guarded_flow') and scored with toBe(before). It was green only because registerInstantScript resolves on a microtask — a property of code the test does not own. Now:

constguards=awaitrecordGuards(GUARD_MS,async()=>{result=awaitengine.execute('guarded_flow');});expect(guards).toHaveLength(3);// one guard per guarded nodeexpect(stillPinningTheLoop(guards)).toBe(0);// and none outlived its race

2. The gate (#10785 part 1)

scripts/check-refd-timer-probe.mjs + pnpm check:refd-timer-probe, wired into lint.yml.

It bans the raw probe outside one approved module — the cards' cheap, robust formulation. It does not attempt "is there an await between these two readings": that is an AST question, and a text scan answering it approximately fails silently on spellings it does not know, which is the failure mode AGENTS.md already records for source-scanning gates. A gate whose default is a silent pass is the failure mode, not a weaker fix.

Two design points worth review:

  • Matched by IDENTIFIER, not by process. receiver. A member access is one route; const { getActiveResourcesInfo } = process, a named import from node:process and process['…'] are others, and matching the receiver leaves a silent hole per route. The one evading spelling — a name assembled at runtime — is stated in the header rather than left to be discovered.
  • Comments are masked (js-comment-mask.mjs). The instrument's docblock, four core suites' fake-timer comments and the gate's own header all have to name the banned probe to explain the rule. A gate that forces authors to reword prose to dodge a scanner teaches them the scanner is noise.

It also cannot report a vacuous green: "nobody reads the probe" is equally true of a tree where the instrument was deleted or gutted, so the sweep asserts the approved module is present and still reads it.

Registration. A check:* key in root package.json, the dominant convention (70+ keys), so the remedy the gate prints is a command that exists. The #9465 fence scopes that file's @changesets/cli range and its version script — the parenthetical in the issue body is scoping, not illustrative — not the file. #10785's body says otherwise; that is the over-broad reading. This is not my claim alone: the Step-collector gate comment landed on main today says the same thing in as many words, while choosing the other spelling on its own merits.

3. The toBeLessThanOrEqual leg (#10785 part 2)

packages/core/src/timeout-guard.test.ts. #10661's repair was deliberate and documented, so it is superseded with an explanation, not silently overwritten — the docblock now carries both the original reasoning and why the instrument replaces it. All three legs move to recordGuards/stillPinningTheLoop, and the assertion is an exact toBe(0) again. The third leg's guard is supposed to fire; what it pins is now stated rather than implied.

The file's other probe use — arms a REF'D timer — is a genuinely synchronous window, so it keeps the raw reading, now via the instrument's refdTimeouts(), with a comment saying exactly which property makes it sound.

Ablations — predictions written down before each run

Every mutation was proven on disk by marker counts (an editing tool's exit code is not evidence), and every restore leg proved git diff --quiet.

A — the converted pin reds on a real leak. Delete clearTimeout(this.timer) from TimeoutGuard.reclaim(). Predicted: leg 1 red with expected 1 to be +0, plus two pre-existing sibling pins. Observed, exactly:

× leaves no ref'd timer behind on any of the three outcomes
AssertionError: expected 1 to be +0
× arms a REF'D timer, ... AssertionError: expected 6 to be 5
× clears the timer, ... AssertionError: expected 1 to be +0
Tests 3 failed | 8 passed (11)

B — the fifth site reds on a real leak. Delete clearTimeout(guard) from executeWithTimeout's finally. Predicted expected 3 to be +0. Observed verbatim: AssertionError: expected 3 to be +0.

C — the gate's self-test cannot pass without the rule. Make probeSitesIn() return []. Predicted exit 1. Observed exit 1, 9 cases failing. ⚠️The mechanism differed from my prediction: I expected the red cases to fail as "expected red, got green", but gutting the detector trips the vacuous-green limb first, so most fail as "red as expected, but the message does not name …". Direction right, mechanism not as written — recorded rather than smoothed over.

D — the card's part-2 arithmetic, reproduced. Same leaking reclaim(), plus two foreign timers armed to expire inside the window (modelling shard load deterministically instead of waiting for a flaky shard). Scratch harness, deleted; git diff --quiet clean after.

ABLATION-D before=3 after=2 delta=-1 guardsArmed=1 stillPinningTheLoop=1

subject +1, two foreign −2, reading = before − 1toBeLessThanOrEqual passed on a real leak, while the instrument reported 1 on the identical tree. That is #10785 part 2, measured.

The gate's real-tree positive control. Before the conversions, on the tree as it stood, the gate named all three live sites and exited 1 — timeout-guard.test.ts:18, timeout-guard.test.ts:162, engine.test.ts:2013. Turning those green is what this PR does.

Verification

Gate union re-run after the final commit, at 46fa7102a1, all green — including every family node scripts/pm/dispatch-gates.mjs derived from the real diff:

check:refd-timer-probe · check:cross-package-test-inputs (13 packages, unchanged) · check:entry-guard · check:parse-guard · check:nul-bytes · check:ratchet-remedy-authority (117 swept; this gate classifies excluded — it hands out no ratchet-expanding remedy, so it needs no control entry) · check:type-check-coverage (65/78, debt 436 unchanged) · check:test-source-alias · check:type-source-resolution · check:slot-lookup · check:query-options-erasure · check:engine-double-contract · check:where-matcher · check:kernel-hook-pairs · check:required-contexts · check:shard-attestation · check:workflow-status-functions · check:node-version · check:override-consistency · check-step-collectors · check-ci-filter-parity · check-changeset-fixed · check-plugin-teardown-shape.

Tests at the same commit: @objectstack/core37 files / 887 tests passed, @objectstack/service-automation84 files / 998 tests passed, @objectstack/refd-timer-testkit typecheck clean.

Declared narrowing:check:type-check-debt --re-measure was not run locally — it refuses without the full ledgered dependency closure built (35 packages). Instead I measured the one package this PR could have moved, directly: tsc -p packages/services/service-automation/tsconfig.json reports exactly 3 errors, its frozen value, with the TS6059 gone. CI runs the farm version.

No changeset: test files, a private (unpublished) package, a gate script and its registration. No published package's contents change — the two devDependencies lines are not installed by consumers. skip-changeset applied.

Concurrency check: two siblings were in flight on scripts/**. Both landed while this branch was open (c9636de1fd, 409077e93c, touching check-type-check-coverage.mjs and check-dispatcher-error-vocabulary.mjs) and were merged in cleanly; this diff touches neither file. Worth flagging as a near miss: git reset --soft origin/main re-anchors to wherever origin/main points now, not to the commit you merged, so both of those landed commits briefly appeared inside this branch as deletions — silently, exit 0. Caught by reading git status before pushing; the final diff is the 14 files listed above and nothing else.


Generated by Claude Code

…reach, and gate the raw probe
The class — "a process-global `getActiveResourcesInfo()` timer count scored
across an `await`" — has been found five times over four cards, twice as a
shard-only intermittent red (once in CI, once in the merge queue, where it
stalls every lane at once). This converts the fifth site, closes the class with
a gate, and converts the one leg whose repair could still hide a real leak.
The load-bearing decision is where the instrument lives, because
`service-automation` could not reach `packages/core/src/refd-timer-probe.testkit.ts`
at all. It moves to a private, test-only workspace package
`@objectstack/refd-timer-testkit`, reached by a bare specifier. The two
alternatives were measured, not argued: a relative import into another package's
`src` lands TS6059 in the consumer's own frozen type-check debt (3 -> 4, a
shrink-only ledger with no tolerance band), and publishing a subpath on
`@objectstack/core` would put a test helper on a runtime package's public npm
surface under semver.
Closes#10783Closes#10785
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/qa/refd-timer-testkit/package.json, packages/qa/refd-timer-testkit/tsconfig.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 58563be069a70d09c4d9cc5f522bb27c5f8a78b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2c73bd691ecfe40874768437c04fea7aec1464a8 — the merge of head 46fa7102a1ebee531938685eaed7c03516b943aa into base 58563be069a70d09c4d9cc5f522bb27c5f8a78b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2c73bd691ecfe40874768437c04fea7aec1464a8 && git checkout 2c73bd691ecfe40874768437c04fea7aec1464a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 58563be069a70d09c4d9cc5f522bb27c5f8a78b2 46fa7102a1ebee531938685eaed7c03516b943aa && git checkout -B drift-repro 58563be069a70d09c4d9cc5f522bb27c5f8a78b2 && git merge --no-ff 46fa7102a1ebee531938685eaed7c03516b943aa
node scripts/docs-audit/affected-docs.mjs --json 58563be069a70d09c4d9cc5f522bb27c5f8a78b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@github-actionsgithub-actionsBot added ci/cd dependencies Pull requests that update a dependency file tests tooling labels Aug 21, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gateteststooling

Projects

None yet

1 participant

@claude