Skip to content

test(cli): the option-B acceptance pin — a two-package fixture booted through every entry path with the flattened collections absent - #15039

Merged
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-15004-option-b-acceptance-probe
Sep 4, 2026
Merged

test(cli): the option-B acceptance pin — a two-package fixture booted through every entry path with the flattened collections absent#15039
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-15004-option-b-acceptance-probe

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #15004

Reader program 1/4 of the ADR-0130 D4 option-B ruling (#14512 comment 5528589044, maintainer 2026-09-03, decision batch #23). This is the acceptance pin the ruling says the reader half does not land without.

Production files touched: zero. Three new test files plus one test-layer tsc config, which a gate required — see "The one file outside test/" below.

What landed

File What it is
packages/cli/test/fixtures/option-b-collection-zoo.ts Two ordinary defineStack packages — an App and a module that depends on it — carrying one member of every collection family, split across BOTH so no reader passes by looking at one. Composed with manifest: 'preserve', in the two shapes.
packages/cli/test/fixtures/option-b-reader-probe.ts The instrument. Writes the compiled artifact the way os build writes it (real lowerCallables, real parse, real sibling ESM runtime module), then runs every reader over both shapes.
packages/cli/test/option-b-reader-acceptance.pin.test.ts The pin: OPTION_B_LOSSES, a shrink-only ledger asserted by set equality, plus the additive baseline and the anti-vacuity control.

The option-B shape is DERIVED, not transcribed

The key set separating the shapes is ObjectStackDefinitionSchema shape keys INTERSECT AssembledPackageBodySchema shape keys — "the collections a package owns" — and its complement is the seven artifact-envelope keys packages/spec/src/assembled-package-body.test.ts classifies. A collection family added to the stack schema next month joins this probe automatically instead of sitting silently outside it. #14877 is to publish that key set as an export; when it lands this fixture should read it instead of deriving it, and nothing else changes.

Measured, matching the enumeration in #14512 comment 5523603341 exactly:

additive top-level keys: actions data datasourceMapping datasources functions hooks jobs manifest objects packages permissions positions translations
optionB  top-level keys: manifest packages
packages[] bodies:  com.example.probe.orders -> objects, functions, actions, jobs, hooks
                    com.example.probe.core   -> permissions, objects, datasources, data, datasourceMapping, translations, positions

Every row calls a real reader. None re-implements one.

A row either invokes a reader this repo ships and reports its return value, or boots a real kernel carrying the real AppPlugin and reports what that plugin HANDED to a subsystem. No row reads bundle.COLLECTION and calls that a measurement — a row shaped like the read it is watching stays red forever after the reader beside it is fixed, and a gate that cannot go green gets deleted.

AppPlugin reads jobs / data / translations / datasources / datasourceMapping / objects inline inside a 950-line start(), with no exported reader to call, and the fold cards 2/4 and 3/4 land could be at the constructor, at each read, or anywhere between. What cannot move is the far side: the plugin has to hand the collection to a subsystem, and the subsystems are ordinary kernel services. The probe registers those services and records what arrives, so those rows measure the contract rather than the code.

Both entry paths, all five boundaries

They share no seam, so covering one would be half a pin.

Boundary How the probe reaches it
B1 loadArtifactBundle The artifact is written to disk and loaded through it; collectBundleActions / collectBundleHooks / collectBundleFunctionEntries and a booted AppPlugin run over the returned bundle
B2 os serve / os dev config-module load The same readers plus appSecurityPluginOptions and a booted AppPlugin, over the from-source composed config
B3 os build config-module load Same loader as B2, which hands its module export on untouched — the readers it drives are the B2 rows
B4 os migrate's own second loadConfig Same; its AppPlugin wrap is the B2 boot rows
B5 resolve-project-database resolveStandaloneDatabase called directly on the written artifact — this is the row that answers config-datasource:probe_primary today and unified-default under option B

createStandaloneStack is driven end to end as well, so B5 and B1 are exercised in the order and the composition a real standalone boot runs them in.

The red leg — reverse-verified, per the card's acceptance

Run on origin/main 33681eaef with OPTION_B_LOSSES emptied (the mutation was confirmed on disk — 24 ledger lines to 0 with the empty literal present — before the run, and the file restored from HEAD after it):

AssertionError: A subsystem lost a collection that the ledger does not carry — this is the
failure #15004 exists to make loud. An option-B artifact reaches it with the collection
ABSENT and NOTHING THROWN.

  B1 · AppPlugin declared-datasource auto-connect (compiled artifact) · datasources
  B1 · AppPlugin job scheduling (compiled artifact) · jobs
  B1 · AppPlugin objects handed to datasource connect (compiled artifact) · objects
  B1 · AppPlugin ql.setDatasourceMapping (object routing) (compiled artifact) · datasourceMapping
  B1 · AppPlugin seed datasets merged (compiled artifact) · data
  B1 · AppPlugin translation loading into the i18n service (compiled artifact) · translations
  B1 · createStandaloneStack surfaced objects (CLI tier resolution + engine/driver auto-registration) · objects
  B1 · createStandaloneStack surfaced permissions (ADR-0056 D7) · permissions
  B1 · createStandaloneStack surfaced positions · positions
  B1 · plugin-security appSecurityPluginOptions over the artifact-serve config (default permission set) · permissions
  B1 · runtime collectBundleActions (action dispatch registration) · actions + objects[].actions
  B1 · runtime collectBundleFunctionEntries (declared function effect) · functions
  B1 · runtime collectBundleHooks (declarative hook binding) · hooks
  B2 · AppPlugin declared-datasource auto-connect (from source) · datasources
  B2 · AppPlugin job scheduling (from source) · jobs
  B2 · AppPlugin objects handed to datasource connect (from source) · objects
  B2 · AppPlugin ql.setDatasourceMapping (object routing) (from source) · datasourceMapping
  B2 · AppPlugin seed datasets merged (from source) · data
  B2 · AppPlugin translation loading into the i18n service (from source) · translations
  B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions
  B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions
  B2 · runtime collectBundleFunctionEntries over the from-source config · functions
  B2 · runtime collectBundleHooks over the from-source config · hooks
  B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources

Full option-B report:
  LOST     B1 · runtime collectBundleActions (action dispatch registration) · actions + objects[].actions = 0
  LOST     B1 · runtime collectBundleHooks (declarative hook binding) · hooks = 0
  LOST     B1 · runtime collectBundleFunctionEntries (declared function effect) · functions = pure
  LOST     B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources = unified-default
  LOST     B1 · createStandaloneStack surfaced objects (CLI tier resolution + engine/driver auto-registration) · objects = 0
  LOST     B1 · createStandaloneStack surfaced permissions (ADR-0056 D7) · permissions = 0
  LOST     B1 · createStandaloneStack surfaced positions · positions = 0
  LOST     B1 · plugin-security appSecurityPluginOptions over the artifact-serve config (default permission set) · permissions = none
  LOST     B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions = none
  LOST     B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions = 0
  LOST     B2 · runtime collectBundleHooks over the from-source config · hooks = 0
  LOST     B2 · runtime collectBundleFunctionEntries over the from-source config · functions = 0
  LOST     B1 · AppPlugin job scheduling (compiled artifact) · jobs = 0
  LOST     B1 · AppPlugin declared-datasource auto-connect (compiled artifact) · datasources = 0
  LOST     B1 · AppPlugin objects handed to datasource connect (compiled artifact) · objects = 0
  LOST     B1 · AppPlugin ql.setDatasourceMapping (object routing) (compiled artifact) · datasourceMapping = 0
  LOST     B1 · AppPlugin translation loading into the i18n service (compiled artifact) · translations = no i18n service
  LOST     B1 · AppPlugin seed datasets merged (compiled artifact) · data = 0
  LOST     B2 · AppPlugin job scheduling (from source) · jobs = 0
  LOST     B2 · AppPlugin declared-datasource auto-connect (from source) · datasources = 0
  LOST     B2 · AppPlugin objects handed to datasource connect (from source) · objects = 0
  LOST     B2 · AppPlugin ql.setDatasourceMapping (object routing) (from source) · datasourceMapping = 0
  LOST     B2 · AppPlugin translation loading into the i18n service (from source) · translations = no i18n service
  LOST     B2 · AppPlugin seed datasets merged (from source) · data = 0

 Test Files  1 failed (1)
      Tests  1 failed | 5 passed (6)

24 subsystems, across all three packages the program scopes. The other five tests — the additive BASELINE, the packages[] control, the shape derivation, the ledger-has-no-phantom-rows check and the boundary-coverage check — all pass in that SAME run, which is what makes the red a discrimination rather than a broken fixture.

Two measured corrections to the enumeration, both in its favour

  1. functions is not lost on the compiled path — it is CORRUPTED, which no presence-check would have caught. mergeRuntimeModule writes the sibling ESM module's handler map onto bundle.functions unconditionally, so the CALLABLES survive an option-B artifact. What does not survive is what the function declared about itself: the top level carried { handler, effect: 'writes' }, the module supplies a bare callable, and normalizeFlowFunctionEntry defaults it to effect: 'pure'. A declared writer comes back through this reader as a pure function — A script node's function is contractually pure, but nothing enforces it — a function that writes makes its run under-report #4396's silent un-declaring in a third spelling, with its writes counted as none. A row COUNTING entries here reports 1 to 1 and reads as coverage; the committed row asserts the VALUE.
  2. AppPlugin's objects loss is wider than "the seeder". connectDeclared is handed bundle.objects alongside bundle.datasources, so under option B the auto-connect call is not made at all and both collections go dark together.

Why the losses are LEDGERED rather than simply asserted away

The card's acceptance is that the option-B leg is red today. A permanently red test cannot land, so the red is held the way every other measured-state gate in this repo holds one: OPTION_B_LOSSES records exactly which rows lose, and the pin asserts set EQUALITY. That gives four directions at once, and the last two are what a bare expect(...).toBe(0) could not give:

  • a reader taught to read packages[] by 2/4, 3/4 or 4/4 goes RED, naming the ledger line to DELETE — the ledger shrinks one subsystem at a time, and empty is what "the program is done" means;
  • a reader that REGRESSES goes red, naming the row;
  • a new reader nobody enumerated goes red on arrival — the entire point of the card;
  • the probe itself quietly measuring less goes red, because a row that stops being measured stops matching its line.

The ledger is shrink-only, and the failure text says so at the point of failure: adding a line is never how a red build is made green.

The anti-vacuity control

MetadataPlugin / ObjectQLPlugin register through resolveArtifactPackageOrder and therefore already read packages[]. The pin asserts the SchemaRegistry holds ['probe_account', 'probe_order'] after the same boot in BOTH shapes and on BOTH entry paths. That is what proves the option-B fixture really carries every definition under packages[], so every LOST above is a reader losing a collection and never a fixture that shipped an empty package.

The one file outside test/

packages/cli/tsconfig.test.json gains three paths rules. It is not a production file — it is the test layer's own tsc program (include: ["test/**/*"], noEmit), and tsconfig.build.json does not extend it — but it is outside the card's "a fixture and a test", so it is called out rather than buried.

check:type-source-resolution went red on the pin: @objectstack/runtime, @objectstack/objectql and @objectstack/plugin-security were newly reached by that program and resolved through each package's exports map to dist/index.d.ts. For a pin whose entire subject is the readers this program is about to change, a type verdict about the last pnpm build is precisely the vacuous-green direction that gate exists to refuse. The gate's registry is shrink-only and widening it is explicitly not the remedy for a dep exposed through an EXISTING program; paths is.

Three star-less (exact-match) rules, deliberately: @objectstack/objectql publishes a second subpath (./core) which keeps resolving through its own exports map untouched.

Measured, because that registry's own notes record paths billing other packages' diagnostics into the consumer's ledger: with the workspace closure built, pnpm --filter @objectstack/cli check:test-typecheck reports 3 file(s) / 28 error(s) / 6 pinned signature(s) — byte for byte the pre-existing test-typecheck-debt.json. Zero new diagnostics.

What this pin does NOT reach, stated rather than implied

Three reads in the enumeration are expressions inline inside oclif command bodies, with no exported reader and no service on the far side, so no probe short of running the command reaches them. They are named in the test file's header so the next reader does not mistake it for full coverage:

  • serve.ts config.objects gating ObjectQL engine auto-registration, and the sibling gate for storage-driver auto-registration. The ARTIFACT half of both is covered — createStandaloneStack surfaces objects precisely so that path can drive them, and that row is in the table — but the from-source half needs a real os serve.
  • dev.ts readArtifactObjects(), a module-private function with its own JSON.parse(readFileSync(...)). Non-fatal; it goes permanently empty.
  • compile.ts's union authoring-rule run, which under option B would judge an empty stack.

Covering these by asserting config.objects in the pin would be the re-implementation trap above. Cards #15006 and #15007 own those sites, and a probe for them belongs beside whatever seam those cards introduce.

Verification

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands against the real change set (4 paths), re-derived after the tsconfig edit and identical. Everything below ran at e70392ba7, the final commit on this branch.

Check Verdict
pnpm --filter @objectstack/cli exec vitest run test/option-b-reader-acceptance.pin.test.ts Test Files 1 passed (1) · Tests 6 passed (6)
pnpm --filter @objectstack/cli check:test-typecheck OK — 3 file(s) / 28 error(s) / 6 pinned signature(s), unchanged from main
pnpm lint (repo-wide eslint . --no-inline-config) exit 0, no findings
pnpm check:type-source-resolution OK — 118 tsc program(s) across 78 packages
pnpm check:test-source-alias OK
pnpm check:type-check-coverage, check:type-check-debt OK — 218 raw tsc error(s) total, none above its recorded number
pnpm check:nul-bytes, check:cross-package-test-inputs, check:cli-test-child-env, check:engine-double-contract, check:published-files, check:doc-authoring, check:logger-receiver-detach, check:objectql-double-limit, check:page-declaration-shape, check:query-options-erasure, check:refd-timer-probe, check:slot-lookup, check:watch-hint-literal, check:where-matcher all exit 0
the 10 direct-node gates (check-ci-filter-parity, check-closing-keyword-parity, check-comment-mask-adoption, check-comment-mask-corpus, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census, check-undeclared-dep-imports, both docs-audit gates) all exit 0

NOT MEASURED, declared with the unmet prerequisite rather than reported as passed:

  • scripts/check-test-completeness.mjs — exit 3, PREREQUISITE NOT MET: it grades a saved turbo run test log, which only CI produces. Its own text says the family names it with no argument and that the local reading is NOT MEASURED.
  • pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 12 packages outside this card's build closure have no dist/. CI builds the whole tree.

Declared narrowing: the @objectstack/cli vitest suite was not run whole — only this PR's own file. The narrowing is provable rather than hopeful: the diff ADDS three files and edits a tsc-only config, and vitest does not read tsconfig paths (no vite-tsconfig-paths plugin in packages/cli/vitest.config.ts, which this PR does not touch), so no existing test file's resolution moves. CI runs the suite regardless.

skip-changeset is applied: this PR publishes nothing — packages/cli's files field ships dist and bin, and every path here is under test/ or is a noEmit tsc config.

Next

OPTION_B_LOSSES has 24 lines. Cards #15005 (@objectstack/runtime), #15006 (@objectstack/cli) and #15007 (@objectstack/plugin-security) each delete some of them. When it is empty the reader half is done and the emitter half on #14512 can land.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

… through every entry path (#15004)

Reader program 1/4 of the ADR-0130 D4 option-B ruling (#14512 comment
5528589044). Option B removes the flattened top-level collections from a
multi-package artifact so `packages[]` carries each definition once; the
ruled order is readers first, emitter last, and the failure mode that order
exists to contain is a reader nobody enumerated — silent, because the
collection is simply absent and nothing throws.

This commit adds the pin that makes that loud, and nothing else: three new
test files, zero production files.

- `test/fixtures/option-b-collection-zoo.ts` — two ordinary `defineStack`
  packages carrying one member of every collection family, composed with
  `manifest: 'preserve'`, in both shapes. The key set separating the shapes
  is DERIVED from `ObjectStackDefinitionSchema` ∩ `AssembledPackageBodySchema`
  rather than transcribed, so a collection family added later joins the probe
  automatically.
- `test/fixtures/option-b-reader-probe.ts` — the instrument. Every row calls
  a reader this repo ships, or boots a real kernel carrying the real
  `AppPlugin` and reports what that plugin handed to a subsystem. No row
  reads `bundle.<collection>` and calls it a measurement.
- `test/option-b-reader-acceptance.pin.test.ts` — the pin. `OPTION_B_LOSSES`
  records exactly which subsystems lose their collection today; the assertion
  is set equality, shrink-only, audited in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…st tsc program (#15004)

`check:type-source-resolution` went red on the pin: `@objectstack/runtime`,
`@objectstack/objectql` and `@objectstack/plugin-security` were newly reached
by `tsconfig.test.json` and resolved through each package's `exports` map to
`dist/index.d.ts` — a build artifact. For a pin whose whole subject is the
readers the reader program is about to CHANGE, a type verdict about the last
`pnpm build` is the vacuous-green direction that gate exists to refuse.

Fixed the way the gate prescribes for a dep exposed through an EXISTING
program: `paths` rules, never a widening of its shrink-only registry. Three
star-less (exact-match) rules, so `@objectstack/objectql/core` keeps resolving
through the package's own `exports` map untouched.

Measured, because the registry's own notes record `paths` billing other
packages' diagnostics into the consumer's ledger (PR #12570): with the
workspace closure built, `pnpm --filter @objectstack/cli check:test-typecheck`
reports 3 files / 28 errors / 6 pinned signatures — byte for byte the
pre-existing `test-typecheck-debt.json`. Zero new diagnostics.

Also corrects the pin header's reverse-verification count to the measured 24
and records the `effect: 'writes'` -> `effect: 'pure'` row, a loss no
presence-check would have found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions github-actions Bot added the size/l label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

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 f28eb1dff33ada56f01e8cd828de821f4fae5e44packageMentionDocs.

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

复审 PASS —— 两个 open question 都裁给实施席的建议,并且探针的形态比我在卡上写的要求更好

PM 席(epic #14122,session session_01UHvF5hyiZjnCyExFnfQB8m)。同会话披露:本 PR 由我派发,这是同会话复审,不是独立复审。

我在卡上写错了一条验收,实施席给的形态是对的

卡面要求「探针今天在 option-B 腿必须」。照字面做,这个 pin 会一直红到整个读取半程序完成——那样它根本合不进去,后面三张卡也就没有可依附的前置。

实施席把它换成了集合相等的 shrink-only 台账OPTION_B_LOSSES,24 行):今天 option-B 形状下确实丢 24 处,台账恰好列这 24 处,集合相等所以绿;修好一处读取方 → 集合不等 → 红,必须删掉台账那一行;回归、出现未枚举的读取方、或探针自己量少了,同样红。区分度用把台账清空的反向验证来证——清空后 pin 报 24 个子系统丢集合,而同一轮里 additive baseline、packages[] anti-vacuity control 和三个结构测试照常通过(1 failed / 5 passed)。

这既满足我要的「必须能区分」,又保住了「可合入、并且逐行推绿」。我的原措辞被更好的形态取代了,记在这里而不是默默接受。

另外值得记的一条设计:没有一行是把被测读取重写一遍——每行要么调用仓库真正出货的 reader(collectBundleActions / collectBundleHooks / collectBundleFunctionEntries / resolveStandaloneDatabase / createStandaloneStack / appSecurityPluginOptions),要么启一个真的 LiteKernel 带真的 AppPlugin 再记录它交给子系统的东西。理由在 pin 的头注释里写明了:形状照抄被测读取的行,在那处读取修好之后会继续红,然后就会被删掉。这正是这类 pin 最常见的死法。

两种形状之间的键集是从 ObjectStackDefinitionSchema ∩ AssembledPackageBodySchema 派生的,不是手列的,所以新增集合家族会自动进探针。

Open question 1 — packages/cli/tsconfig.test.json 的三条 paths:裁 A,保留

卡面写「生产文件改动数应为零」,实际是零;这一个文件在 test/ 之外,实施席主动 flag 而不是埋掉,做得对。保留的理由成立:check:type-source-resolution 因为该 tsc 程序新触达三个包而变红,那个门自己的失败文本就点名 paths 是修法,且它的 registry 是 shrink-only 不许加宽;这个文件是测试层自己的程序(include test/**/*noEmit),tsconfig.build.json 不 extend 它,出货物一字不变。paths 唯一有记录的风险(把别的包的诊断计进消费方的 shrink-only 台账,PR #12570 的教训)实测为零——check:test-typecheck 输出与既有 test-typecheck-debt.json 逐字节相同。选项 B 的前提不成立(没有同时依赖这三个包的别的包),C 会砍掉大半张卡。

Open question 2 — 四处内联读取不在探针内:裁 A,留给 #15006,但缺口我已前移到那张卡上

serve.ts 两个 config.objects 门的 from-source 腿dev.tsreadArtifactObjects()compile.ts 的 union authoring-rule run,都是 oclif 命令体内的内联表达式,没有可附着的接缝。裁 A 的理由与实施席一致:这些站点本来就归 #15006,那张卡为了修它们总要引入可调用的边界,pin 挂在那里才不是硬凑;而选项 B(在 option-B 配置上断言 config.objects)正是上面说的「照抄读取形状」的死法。

⚠️ 但这个缺口必须让下游看见,否则 #15006 的实施者会以为探针兜底了。已作为 5530257178 前移到 #15006,连同两条新增验收(四处各自补 pin、并按集合相等的规则维护 OPTION_B_LOSSES 台账)。这里也记一句容易读岔的事实:两个 serve.ts 门的产物腿今天是覆盖的(经 createStandaloneStack 输出的 objects 键),from-source 腿没有——「探针对 serve.ts 有行」只真了一半。

核过的其余项

CI 在写这条时仍在跑且无红。全绿后我翻 ready。⛔ 不 approve、不挂 auto-merge:那是人类 reviewer 的。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l 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.

reader program 1/4 — the option-B acceptance probe: a two-package fixture booted through every entry path with the flattened collections absent

3 participants