Skip to content

test(service-cluster-redis): pin the resolved ioredis/ioredis-mock pair the inertness declaration names - #17522

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-15986-ioredis-mock-pair-pin
Sep 10, 2026
Merged

test(service-cluster-redis): pin the resolved ioredis/ioredis-mock pair the inertness declaration names#17522
os-sales merged 2 commits into
mainfrom
claude/issue-15986-ioredis-mock-pair-pin

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #15986

Clause-②: no

The route

The card presented three routes and called itself 「a policy call」; it then sat in pm:awaiting-maintainer for exactly that reason and the maintainer answered. Quoted, not translated:

判据:ioredis-mock 的惰性声明只要两个依赖区间任一移动就失效,而没有任何东西守它 —— 请求是 pin 住它点名的那对版本。这是测试基建面,⛔ 不触任何已发布包的运行时依赖(人工地板管的是新增运行时第三方依赖,不是钉住既有测试替身)。

⇒ Route A, pinned. The "re-derive the overload intersection in CI" middle option is the road not taken and is not implemented here. One dissent is recorded at the bottom, for the record only — it did not change what was built.

What this adds

One new file, packages/services/service-cluster-redis/src/ioredis-pair.pin.test.ts. Nothing else. No manifest change, no lockfile change, no dependency change, no CI change.

It defends the inertness declaration at the top of src/redis.contract.test.ts — the section ## The double is one major version behind the client it doubles, whose entire deliverable is a paragraph of prose, and which states its own expiry condition:

If the ioredis or ioredis-mock range in package.json moves — OR if the version either one RESOLVES to moves under an unchanged caret range, which a lockfile bump alone will do — this paragraph expires and the diff has to be re-taken.

Four assertions, one per limb of that clause plus a coupling limb:

# Assertion Reads
1 ioredis resolves to 6.0.0 installed tree
2 ioredis-mock resolves to 8.13.1 installed tree
3 the declared ranges are still ^6.0.0 (dependency) and ^8.13.1 (devDependency) this package's package.json
4 the declaration itself is still present and still names the pinned pair src/redis.contract.test.ts

Both reads stay inside the package (src/ to the package root, and a sibling source file), so neither is a check:cross-package-test-inputs escape; that gate is green below.

Which source the resolved pair was read from, and why that one

The installed treecreateRequire(import.meta.url).resolve('ioredis/package.json') and the same for ioredis-mock, then that manifest's own version field.

The refinement carried on the card is that the claim can expire with no range moving at all: a lockfile-only bump moves the resolved version under an unchanged caret, and a pin reading only the declared range strings stays green through exactly that. So the pin had to key on resolution. Two sources could answer that, and they are not equally honest:

  • pnpm-lock.yaml records what pnpm would install. (Its importer entry for this package reads ioredis: specifier ^6.0.0 / version 6.0.0 and ioredis-mock: specifier ^8.13.1 / version 8.13.1(...), so it agrees today.) It is also a file another open PR currently holds, and reading it would make the pin a cross-package input needing its own declaration.
  • The installed tree is what is installed — the very manifests the import RedisMock from 'ioredis-mock' two files over resolves to, in the same process, at the same moment the suites run. The declaration's own words for the ioredis leg are "the version resolved in this workspace", and this is that number rather than a proxy for it.

Ablation 1 below shows the difference is not theoretical: with the resolution moved and the caret range untouched, assertion 3 stays green and assertion 2 goes red.

Why assertion 4 exists

The card names the exact failure this design is aimed at:

a pin that fires on routine maintenance trains people to edit the pin rather than re-do the measurement, which would be worse than no pin.

Assertion 4 is the answer to it. The pinned pair must also appear in the declaration's own text, so editing the constants in the pin is not sufficient to get back to green — the header has to move too, and moving the header honestly means re-taking the measurement. Ablation 2 drives exactly that scenario and it stays red.

The failure message is the other half. Whoever sees this go red is mid-bump and has never read the original measurement, so the message tells them what expired, where it is written, and what the remedy is. Rendered output from ablation 1, verbatim:

AssertionError:
The ioredis / ioredis-mock pair this package's inertness declaration was
measured against has moved:

    ioredis, resolved on disk: pinned 6.0.0, found 6.1.0

That declaration is the section "## The double is one major version behind the client it doubles"
at the top of packages/services/service-cluster-redis/src/redis.contract.test.ts. It states, in its own words, that it
expires when either range moves OR either resolved version moves — which
is what just happened. Every "inert" conclusion in it now describes a
pair that is no longer what this package installs.

⛔ Do NOT edit the pinned constants to match. This file is not the claim,
it is the tripwire on the claim, and editing it green re-asserts a
measurement nobody has taken. It would not even work: the last assertion
in this file requires the declaration's own text to name the same pair.

Re-do the measurement instead:
  1. Re-take the diff that section describes, for the NEW pair — the type
     declarations of every command `src/*.ts` issues, of `multi()` and
     `exec()`, and of the `RedisOptions` keys `client.ts` sets.
  2. Rewrite the section with the new pair and whatever it now reads.
  3. Only then update the constants here, in the same commit.

If the re-measure finds the gap is no longer inert, the fix is upstream —
the dependency or the double — not this pin and not the declaration.

RED before GREEN

GREEN, at the branch head, whole package:

Test Files  3 passed (3)
     Tests  37 passed (37)

os-verify-lock: VERDICT command-exit 0 (the pin alone: 4 passed (4), VERDICT command-exit 0).

Ablation 1 — a simulated lockfile-only bump

The package-local node_modules/ioredis symlink was replaced with a stub manifest declaring 6.1.0, leaving the tracked caret range untouched. That is the move a range-only pin cannot see.

On-disk proof, not an editor's exit code:

PRE  : is-symlink=yes target=../../../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis
PRE  : resolver says ioredis version = 6.0.0
PRE  : tracked range in package.json = ^6.0.0
POST : is-symlink=no
POST : injected-text count = 1
POST : removed-text count  = 0
POST : resolver says ioredis version = 6.1.0
POST : tracked range in package.json = ^6.0.0  ⇐ UNCHANGED, this is the point
POST : git diff HEAD (tracked tree) = []

Result — os-verify-lock: VERDICT command-exit 1:

❯ src/ioredis-pair.pin.test.ts (4 tests | 1 failed)
    × still resolves the ioredis version the declaration was measured against
Tests  1 failed | 3 passed (4)

The three that passed include assertion 3, the declared-range limb. That is the measurement behind this PR's central design choice: a pin asserting only that the ranges still read ^6.0.0 and ^8.13.1 would have been green on this run.

Restored, and proven by state rather than by exit code:

RESTORED: is-symlink=yes target=../../../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis
RESTORED: target matches original = YES
RESTORED: resolver says ioredis version = 6.0.0

Ablation 2 — the bump is "fixed" by editing the pin

The scenario the card is afraid of, driven end to end: the double bumps to 8.14.0 (stub manifest behind the package-local symlink), the manifest range is edited to ^8.14.0, the pinned constants are edited to match — and the declaration is left alone.

On-disk proof, with the HEAD blob hashes recorded first:

HEAD blob pin      = 6c77cc50945cb23daeeb448227ff953586879d87
HEAD blob manifest = a96d1985f9d661cdbe41b43ada972bf13c4ff5b2
POST resolver ioredis-mock version = 8.14.0
POST pin  injected "'ioredis-mock': '8.14.0'"  count = 1
POST pin  injected "'ioredis-mock': '^8.14.0'" count = 1
POST pin  removed  "'ioredis-mock': '8.13.1'"  count = 0
POST man  injected count = 1  removed count = 0
POST pin  hash-object = cbdacbabe7412766e7d0e526d3a99e7fc302aeab   (differs from HEAD blob: YES)
POST man  hash-object = 40516b136d43744da7752156d1b0d4bba501e986   (differs from HEAD blob: YES)
POST decl hash-object = 8d805a85e163e4265204f8db512905b5c3fced82   ⇐ the header is UNTOUCHED and therefore stale

Result — os-verify-lock: VERDICT command-exit 1:

❯ src/ioredis-pair.pin.test.ts (4 tests | 1 failed)
    × keeps the declaration itself present and naming the pinned pair
Tests  1 failed | 3 passed (4)

Assertions 1, 2 and 3 all went green under the edit — and the pin still refused. Editing it is not a way out.

Restored by state, all four readings:

RESTORED pin  hash-object = 6c77cc50945cb23daeeb448227ff953586879d87  matches HEAD blob: YES
RESTORED man  hash-object = a96d1985f9d661cdbe41b43ada972bf13c4ff5b2  matches HEAD blob: YES
RESTORED symlink target matches original: YES
RESTORED git diff HEAD  = []
RESTORED git status --porcelain = []

Both ablation scripts carry trap on EXIT INT TERM with absolute paths, refuse to start on an empty HEAD blob hash, and restore with git checkout HEAD -- ... rather than a bare git checkout --.

Premises verified before writing anything

Premise Reading Control
the whole surface is 3 tracked files in 1 package git grep -l ioredis-mock excluding the lockfile and dist returns package.json, src/kv.transaction.test.ts, src/redis.contract.test.ts the broader ioredis grep returns 13 files, so the narrow grep is not silently empty
#17017 (named on the card as 根因) is not a blocker closed / completed, labels tooling priority:p2 domain:skills
#16053 (the double may itself be defective) was dismissed closed / not_planned
the resolved pair is 6.0.0 / 8.13.1 installed tree, and the lockfile importer entry agrees the stub-manifest ablations move the same reading to 6.1.0 / 8.14.0, so the read is live
the newest Claim: names this branch it does

Clause-② — both limbs

  • Limb A — does it widen the accept set? No. It is a pure narrowing: a version move that was previously accepted in silence is now refused. Nothing that was refused becomes legal.
  • Limb B — does it add or change a public surface? No. The new file exports no symbol, adds no key to any payload, and never reaches the published artifact. Measured, not assumed: files[] is ["dist","README.md","CHANGELOG.md"], the package builds from entry: ['src/index.ts'] only, and after a build, ioredis-pair / PINNED / version pair pin hit zero times across dist/, README.md and CHANGELOG.md — positive control RedisKV hits 4 published files.

Clause-②: no. This is test infrastructure and touches no published runtime surface, which is what the claim comment predicted.

Changeset

skip-changeset, on the same measurement as Limb B: nothing that publishes moves. pnpm check:published-files green.

Gates

Derived from the actual diff with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the merged tree (a220174a5): change set 1 path(s) vs merge base 634f23de2, 53 commands. All 53 run, each exit code captured before any pipe.

  • 50 of 53 exit 0. Named among them: check:cross-package-test-inputs, check:type-check-coverage, check:nul-bytes, check:published-files, check:test-source-alias, check:engine-double-contract, check:dts-closure, check:tier-file-adoption, check:type-source-resolution.

  • 3 exit 3 — PREREQUISITE NOT MET, i.e. NOT MEASURED, not a pass and not a failure:

    • check:dual-build-cjs-loads — "this gate reads built output, and some package has no dist/" (74 packages listed).
    • check:lean-entry-closure — "this gate loads BUILT entry points, and some target is absent".
    • check:type-check-debt — "--re-measure cannot run: 27 workspace dependenc(ies) ... have no built type entry point on disk".

    All three want a whole-repo build, which is CI's run on a shared box. Each is insensitive to this diff in principle — a src/*.test.ts that never reaches dist/ cannot move a gate that reads dist/ — but that reasoning is stated here as reasoning, and ⛔ is not rounded up into a green.

Package-level, all under the shared verify lock, each quoting the lock's own verdict line:

Run Verdict
pnpm --filter '@objectstack/service-cluster-redis^...' build VERDICT command-exit 0
pnpm --filter @objectstack/service-cluster-redis test VERDICT command-exit 037 passed (37)
pnpm --filter @objectstack/service-cluster-redis typecheck VERDICT command-exit 0

typecheck really covers the new file, rather than being claimed to: tsc --noEmit --listFiles puts src/ioredis-pair.pin.test.ts in a 410-file program alongside all three other src/*.test.ts.

Lint — a declared narrowing, with its three pieces of evidence. pnpm lint is eslint . --no-inline-config, a whole-repo scan CI owns. Run here instead: eslint on the one added file, 0 errors, 0 warnings, exit 0.

  1. The checked population is read from eslint's own config, not guessed: the base block is files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED, and the added file matches it (eslint linted it rather than skipping it).
  2. The file count is read from --format json: 1.
  3. The invariance: this repo runs one eslint.config.mjs and it never enables type-aware linting for any fileeslint --print-config on the added file returns parserOptions = {"ecmaVersion":"latest","sourceType":"module"}, no project and no projectService. With no type information crossing file boundaries, a new file cannot move the verdict on any untouched file. eslint.config.mjs records the same fact with its own positive control.

All three present ⇒ this is a measurement, not a skipped run.

Acceptance notes

Out of scope, observed, deliberately not fixed here and not filed:

  • The declaration's v5 leg cannot be defended offline. "5.11.1, newest release satisfying the mock's ^5 peer" is a registry fact, not a workspace one — no ioredis 5.x is installed here (pnpm resolves the mock's peer onto the 6.x copy). A newer 5.x release ages that leg with no local signal, and no offline pin can see it. Carrier: the limitation is written into the new file's header under "What it deliberately does NOT check", so the next person to bump either dependency reads it there. No card, because there is no defect and nothing to do.
  • @types/ioredis-mock@8.2.7 is in the tree, pulled in as ioredis-mock@8.13.1's own peer (visible in the lockfile's resolution key). This confirms the reading already recorded on the card — present in the store, on no @types directory tsc walks — and is ⛔ not a reason to revisit adopting it. Recorded, not filed.

Dissent, for the record only

Having built it, I still think the middle option — re-deriving the overload intersection in CI — is the better long-run shape, for the reason the card's own follow-up gives: it measures the thing the claim is about and is immune to how the version moved. The ruled route was implemented as ruled; a seat does not silently re-rule a maintainer's answer, and this paragraph is a note, not a request.


Generated by Claude Code

…ertness declaration names

`redis.contract.test.ts` closes the `ioredis-mock@8` (peer `ioredis@^5`) vs
`ioredis@^6` gap by measuring it and declaring it inert. The whole deliverable
is prose, and that prose states its own expiry condition — either declared
range moving, or either RESOLVED version moving under an unchanged caret.
Nothing checked it, so a routine bump would leave a stale header still reading
as authoritative.

This pin keys on the resolved pair read off the installed tree (6.0.0 /
8.13.1), not on the declared range strings, because a lockfile-only bump moves
the resolution under an unchanged caret and a range read cannot see it. The
declared ranges are pinned as well, since the expiry clause names them too.

The last assertion reads the declaration's own text and requires it to name
the same pair, so editing the pinned constants alone cannot restore green.
That is deliberate: a pin people learn to edit instead of re-measuring would
be worse than no pin, and the failure message says so in the words of someone
who has never read the original measurement.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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 10da5c44e38de726f0dcc4d40b3bf092a3a7feb8packageMentionDocs.

Copy link
Copy Markdown
Collaborator

ACCEPT — 2026-09-10T19:15Z, head a220174a5.

Landing pre-checks, each measured:

  1. In-seat Clause-② review — not owed. Clause-②: no, no needs:contract-review on either carrier, and Limb B is measured on the published artifact rather than argued: files[] is ["dist","README.md","CHANGELOG.md"], the tsup entry is ["src/index.ts"] alone, and after a real build, grepping dist/ + README.md + CHANGELOG.md for the pin's identifiers returns zero hits with positive control RedisKV hitting 4 published files.
  2. Carriersnode scripts/pm/check-clause2-carriers.mjs --pair 17522EXIT 0.
  3. Every check green on this head, not a required subset{success: 28, skipped: 5}, NOT GREEN: none.

What this seat checked beyond green:

  • The ruled route was taken, and the tempting one was refused in writing. The maintainer's answer on The ioredis-mock inertness declaration expires if either dependency range moves, and nothing defends it — pin the version pair it names #15986 was route A — 「请求是 pin 住它点名的那对版本」 — and the delivery implements exactly that, recording its own dissent (that it still finds the re-derive-the-intersection option better long-run) as a note rather than as a reason to do something else. That is the right shape for a dev who disagrees with a ruling.
  • It keys on the RESOLVED pair, which is the whole point. A pin on the declared range strings would stay green through a lockfile-only bump — the exact move that invalidates the measurement. This reads the installed tree (createRequire(...).resolve('ioredis/package.json') and the same for the mock) and cross-checks the lockfile importer entry, which agrees. The choice of source is argued, not defaulted: the declaration's own words for the ioredis leg are 「the version resolved in this workspace」, and pnpm-lock.yaml is held read-only by PR feat(auth)!: adopt better-auth's account-issuer rollback — drop sys_account.issuer, retire the backfill, lift the family to 1.7.3 #17454 in any case.
  • The card's central objection is answered by construction, not by a promise. The card warned that 「a pin that fires on routine maintenance trains people to edit the pin rather than re-do the measurement, which would be worse than no pin」. The fourth assertion reads the declaration's own text and requires it to still name the pinned pair, so editing the constants alone cannot restore green — and ablation 2 proves it: with the stub bumped, the manifest range edited and the pinned constants edited to match, assertions 1–3 all go green and assertion 4 goes red. The failure message then says so to a reader who has never heard of service-cluster-redis's contract test runs ioredis-mock@8 (peer ioredis@^5) against a package that depends on ioredis@^6, with the import seam suppressed by @ts-expect-error #15467, in three numbered steps whose first two are the measurement and the rewrite, with 「update the constants」 demoted to step 3 behind 「only then」.
  • Both ablations restore by state, not by exit code — blob hashes back to the HEAD blobs, symlink target back to the recorded original, git diff HEAD empty, git status --porcelain empty; each script refused to start on an empty HEAD blob hash and carried a trap on EXIT/INT/TERM with absolute paths. Ablation 1 is the measurement behind the design decision: under a simulated lockfile-only bump the declared-range assertion passed while the resolved-version one failed.
  • Three gates exit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED and never rounded up, all one cause (no workspace-wide dist/). 50 of 53 exit 0. The check:type-check-coverage sibling did run and exited 0.
  • One limit declared rather than pretended: the declaration's v5 leg (「5.11.1, newest release satisfying the mock's ^5 peer」) is a registry fact, and no ioredis 5.x is installed here, so a newer 5.x publish ages it with no local signal. Written into the pin's own header under "What it deliberately does NOT check" — which is the section the next person bumping either dependency will reach, since the failure message sends them to that file.

⚠️ This branch's commits carry the model-free trailer pair, which is AGENTS.md's rule — and getting there took ignoring my own dispatch order, which specified a model-named one. That was my defect (correction 109, seat post #6021); the dev flagged it as a standing-text conflict instead of silently resolving it, and resolved it the right way. ⛔ Not a deviation, and it does not count against the delivery.

Flipped ready and armed. ⛔ Not approved and ⛔ not merged by this seat; the merge queue lands it.


Generated by Claude Code

@os-sales
os-sales enabled auto-merge September 10, 2026 19:15
@os-sales
os-sales added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 74f0611 Sep 10, 2026
39 checks passed
@os-sales
os-sales deleted the claude/issue-15986-ioredis-mock-pair-pin branch September 10, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The ioredis-mock inertness declaration expires if either dependency range moves, and nothing defends it — pin the version pair it names

2 participants