Skip to content

feat(scripts): gate a vi.mock override's VALUE shape against the declared export - #8954

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-8903-vi-mock-override-value-guard
Sep 10, 2026
Merged

os-steve merged 2 commits into
mainfrom
claude/issue-8903-vi-mock-override-value-guard

Conversation

@os-steve

@os-steve os-steve commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #8903

Adds scripts/check-vi-mock-override-shape.mjs — a third gate over the vi.mock population, judging the one property neither sibling judges: what the override is worth.

The measurement this gate exists for

Re-derived on this branch, independently of the card. The thirteen drifted useRecordPresence stubs were restored to disk byte-for-byte from PR #8902's pre-image (blob hash a3fe5953ac on RecordDetailView.activityMapIdentity-5878.test.tsx — exactly the pre-image that PR's own diff header names), and both existing gates re-run:

gate with the drift on disk vs. repaired tree
check-vi-mock-specifiers exit 0 verdict line byte-identical (diff empty)
check-vi-mock-inherit exit 0 verdict line byte-identical (diff empty)

The card's central claim holds. check-vi-mock-specifiers judges whether a relative specifier resolves, and these mock the bare @object-ui/collaboration, which its own summary counts under bare (out of scope). check-vi-mock-inherit judges whether the factory obtains and spreads the real module — all thirteen already did, fully compliant with the drift inside them.

One figure moved: the gate's own summary reads 937 bare, not the 935 the card quotes. Re-derived from check-vi-mock-specifiers' own output at aeaa0f6.

The acceptance instrument is mutation, not green

Final run at dac8fe6, against the code this PR ships. Both legs prove the mutation reached disk (anchor count and blob hash) before any result is read; the restore is proved by state (git diff HEAD empty, every blob back to its HEAD hash), never by an exit code.

LEG 1 MUTATE   13 files, 0 failures; every blob hash changed and differs from HEAD
               13 files carry the drifted anchor on disk
               NEW GATE exit=1 — "13 override(s) do not match the declared export"
               13 findings name the expected shape; 0 mentions of COLLAPSED
               check-vi-mock-specifiers exit=0   <- still blind, side by side
               check-vi-mock-inherit    exit=0   <- still blind, side by side
LEG 2 RESTORE  13 files, 0 failures; every blob back to its HEAD hash
               git diff HEAD empty = YES
               NEW GATE exit=0

Each finding names which stub, which export, and what shape was expected:

- packages/app-shell/src/views/RecordDetailView.activityMapIdentity-5878.test.tsx:87
  -- vi.mock("@object-ui/collaboration") overrides useRecordPresence
  declared a function returning an array at packages/collaboration/src/PresenceProvider.tsx:101,
  stub is a function returning an object: () => ({ viewers: [], others: [] })

The same two legs are pinned in the test file end to end, over a 1001-file clean population so the exit code cannot be confused with the vacuity floor — the test asserts COLLAPSED is absent from stderr before it reads the status. Without that filler both legs exit 1 and the exit code proves nothing; that confound is this gate's own defect, one level up.

Which direction can this gate fail in?

It can fail when a judged override's shape conflicts with the declared export — proved above on thirteen real instances.

It cannot fail on an unknown, by design. That is a real limit, stated rather than hidden: an override whose value is a vi.fn(), an identifier or a call is counted and never judged. Certainty or nothing, because a gate that reddens correct code gets deleted rather than fixed. The honest middle is pinned too: () => stubValue is judged at the kind level (function vs function) and never below it, while useRecordPresence: [] against a function export is still a finding.

Route: a new gate, decided on measurement

The suggested route was to extend a sibling first. It is not available, and that was measured rather than argued. Reading a declared shape means parsing TypeScript, so this gate imports typescript. check-vi-mock-inherit.mjs runs in vi-mock-specifiers.yml, which deliberately carries no install step; every pre-install gate's import graph is enforced to be node builtins plus repo-relative modules only (objectui#6148). Injecting that one import into the sibling, blob hash recorded before and after:

node scripts/check-pre-install-import-graph.mjs
  before injection : exit 0
  after  injection : exit 1 -- "scripts/check-vi-mock-inherit.mjs reaches the package `typescript`"

Extending the sibling would have reddened an existing gate, which Zone-1 boundary 2 forbids outright. So: a new gate, homed in lint.yml after pnpm install.

⚠️ A first attempt at that measurement produced a false green — the injected text was mangled by shell quoting, the ts-import anchor count stayed 0, and check-pre-install-import-graph reported exit 0. That reading was discarded, not used. The number above comes from the re-run whose anchor count and blob hash both confirm the injection landed.

Two further properties argue the same way: check-vi-mock-inherit judges 100% of its population, while this predicate is partial by construction (923 of 1496), so fusing them would make one census line mean two things about two denominators; and its predicate is a property of the factory's own text, while this one resolves modules.

lint.yml's skip switch is not a blind spot here — its ignore list is Markdown plus the content, docs and .changeset trees, and both halves of what this gate compares live in TypeScript files. The test pins that with a glob matcher and fails if a pattern matching a TypeScript file is ever added.

The existing population was not reddened

Zone-1 boundary 3 held without needing the ledger. Measured at aeaa0f6:

1496 override(s) found on covered specifiers
 923 judged              -- declared side has a certain top-level kind
 391 to the RETURN shape -- the depth THIS card's drift lives at
  14 export not resolvable
 559 shape not statically certain
   0 mismatches

KNOWN_SHAPE_MISMATCHES is therefore empty: nothing in the tree violates the predicate, so nothing had to be grandfathered, and the gate is hard-red for this class from day one. The 937 bare-specifier sites are untouched — this gate judges override values, not specifiers. The ledger mechanism ships anyway, SHRINK-ONLY: a registered entry that no longer mismatches is itself a failure, so it can never quietly become a blanket. Both behaviours are pinned.

No stub was re-fixed; PR #8902's repair is left exactly as it stands.

Gates

scripts/pm/dispatch-gates.mjs does not exist in objectui, so the families below were derived by hand from the changed-file set. Every exit code was written to a file before it was read.

gate exit
check:vi-mock-override-shape 0
check:vi-mock-specifiers 0
check:vi-mock-inherit 0
check:pre-install-import-graph 0
check:control-bytes 0
check:entry-guard 0
check:unreferenced-sources 0
check:phantom-deps / check:unused-deps / check:self-import 0
check:doc-fences / check-doc-links 0
check-changeset-presence / -no-major / -fixed 0
type-check:scripts 0
lint:root — complete 309-file population, 0 errors 0
pnpm exec vitest run scripts/ — 134 files, 3914 tests 0

lint:root is the eslint invocation that covers scripts/; it was run whole rather than narrowed, so there is no narrowing to justify. pnpm lint (turbo run lint) is per-package and structurally cannot reach scripts/.

Acceptance notes

  • Deviation from the declared file surface: content/docs/guide/ci-cd-pipeline.md is edited. It is not optional — ci-cd-pipeline-doc.test.ts fails any lint.yml step whose command the doc's gate list does not name, in run order, because every gate in that job blocks a merge on a required check.
  • The gate imports COVERED_SPECIFIERS from check-vi-mock-inherit.mjs rather than restating it. A population that drifted between the two gates would be a hole neither reports; the test pins the import.
  • deep (391) rose from 271 once local interface / type-alias references were resolved one hop, with a callable-interface escape so a correct function stub is never reddened. Zero mismatches appeared either way — the increase is power, not noise.
  • Noted, not filed: check-vi-mock-specifiers' bare (out of scope) figure has moved 935 → 937 since the card was written. It is a census line, not a defect.

维护者速读(草稿)

改了什么 — 新增第三道 vi.mock 门禁,判断桩的返回值形状是否与真实导出声明的形状一致。另配套测试、package.json 脚本别名、lint.yml 一个步骤、CI 文档的门禁清单一条、一份空声明 changeset(不发版)。零运行时改动,零已发布面改动。

为什么改 — 现有两道门对这一类漂移完全无判别力:把 13 个漂移桩原样放回磁盘,两道门打印逐字相同的判定行、同样 exit 0。这类失效是最坏的一种——契约一变,桩对了的文件红、桩错了的静默绿,套件从中间裂开,多数派悄悄停止追踪那个 hook。

风险与代价(含回滚) — 风险集中在误报。控制手段是"要么确定,要么不判":任一侧读不确定就记为 unknown,unknown 两个方向都永不构成冲突;可调用的 interface 一律读作 unknown(它运行时是函数)。全树 923 个已判站点零误报,这是规模级的阴性对照。回滚成本极低:删掉一个脚本、一个测试、一行 package.json、一个 workflow 步骤和一条文档 bullet,不牵动任何产品代码。存量 937 处裸 specifier 桩未被触碰,台账为空。

席位意见 — (留空,待维护者)

你要做的 — 确认两点:① 新门禁落在 lint.yml 而非兄弟门禁所在的 vi-mock-specifiers.yml,是因为它需要 typescript,而后者按设计不装依赖(已实测:注入该 import 会让 check-pre-install-import-graph 从 exit 0 变 exit 1);② 台账 KNOWN_SHAPE_MISMATCHES 保持为空是刻意的——本轮无需赦免任何存量,门禁对这一类从第一天起就是硬红。

🤖 Generated with Claude Code

https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB


Generated by Claude Code

…ared export

The two existing `vi.mock` gates judge whether a relative specifier resolves and
whether the factory inherits the real module. Neither judges what the override
is worth: thirteen `RecordDetailView` stubs returned `{ viewers, others }` where
`useRecordPresence` is declared `PresenceUser[]`, and putting them back on disk
made BOTH gates print a byte-identical verdict line and exit 0.

`check-vi-mock-override-shape.mjs` compares the override's value shape against
the export's declared shape, resolving the export through the workspace source
entry and its re-export chain. Both sides are read for certainty and anything
less is `unknown`, which never conflicts in either direction.

It is a new gate rather than an extension because extension was measured to be
unavailable: reading declared types needs `typescript`, and injecting that
import into `check-vi-mock-inherit.mjs` takes `check-pre-install-import-graph`
from exit 0 to exit 1. It therefore runs in `lint.yml`, after the install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
…gate list

`ci-cd-pipeline-doc.test.ts` requires every first-party command the `lint` job
runs to be named in `content/docs/guide/ci-cd-pipeline.md`, in run order: every
gate in that job blocks a merge on a required check, so one nobody wrote down is
a build failure a contributor meets with no way to learn what produced it.

Also types `scan`'s options with JSDoc, matching the sibling gate, so the pin
test can pass `files`, `floors` and `baseline` under `tsconfig.scripts.json`;
and aligns the verdict lines with the two sibling gates' emoji prefixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants