Skip to content

test(qa): ledger out-of-repo consumers’ specifiers against the published exports maps - #15717

Merged
baozhoutao merged 6 commits into
mainfrom
claude/issue-15589-consumer-specifier-ledger
Sep 5, 2026
Merged

test(qa): ledger out-of-repo consumers’ specifiers against the published exports maps#15717
baozhoutao merged 6 commits into
mainfrom
claude/issue-15589-consumer-specifier-ledger

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Part of #15589option A only. Option B is filed as a decision card, #15715, and is deliberately not implemented here.

The gap

An exports map is a packaging contract, and inside this monorepo nothing is sealed: every in-repo consumer reaches any file through a relative import, a vitest alias or a paths entry. So the repo could not observe a sealing regression at all — not as an oversight, but by construction. It cost the same event twice: 17.3.0 sealed @objectstack/cli (#13123), and both breaks were found after publish, by the consumer, during an upgrade — cloud's objectos-runtime (#13662) and hotcrm's hook-body harness (#15325).

A fix therefore has to import knowledge from outside the repo. That is what the ledger is.

What this adds

packages/qa/downstream-contract/consumer-specifiers.ledger.json — hand-curated, with a $comment header carrying purpose, an OWNER line (domain:devx), the shrink-only rule, and the entry shape. Three seeded entries, each with specifier / consumer / since / ratifiedBy:

specifier consumer ratified
@objectstack/cli/console cloud — the objectos-runtime node server #13662
@objectstack/cli/hook-body hotcrm — test/helpers/action-sandbox.ts #15325, PR #15611
@objectstack/cli/package.json hotcrm — the same harness, reading the pinned CLI version #15325, PR #15611

⚠️ hotcrm is the only cited consumer of @objectstack/cli/package.json. #15589 attributes it to cloud as well, and no reading of #13123 / #13662 / #15325 records that, so it is not claimed. An uncited row would make this ledger exactly as trustworthy as the memory it replaces.

test/consumer-specifier-ledger.test.ts holds it against the real maps. Resolving from inside the workspace would prove nothing — this package's own node_modules/@objectstack/cli is a symlink into packages/cli, and tsconfig paths and vitest aliases bypass exports besides. So each ledgered package is packed with pnpm pack, unpacked into a throwaway node_modules outside the workspace, and every specifier is resolved by a child node with nothing of this workspace on its resolution path — createRequire().resolve() for require, import.meta.resolve() for import. Failures name the specifier and the consumer.

It is the technique packages/cli/test/published-subpath-hook-body.pin.test.ts uses (landed with #15611), and the two are deliberately kept separate: that pin asks "does the CLI's map still spell the subpaths this repo ratified", from inside packages/cli. This asks "does every specifier a named out-of-repo consumer imports still resolve". A pin over one package's map cannot see the next package that gains one; the ledger grows a row rather than a file.

The ledger of losses shrank to empty, mid-branch

When this was written, two of the three specifiers did not resolve: ./hook-body and ./package.json were ratified only by PR #15611, then open. A test asserting "every ledgered specifier resolves" would have been permanently red and could not land, and dropping the two entries would have discarded the knowledge. So the red was ledgered: SEALED_TODAY recorded exactly which specifiers this tree did not open, asserted as a set equality so the table could only shrink.

#15611 then landed while this branch was in flight, and the equality did exactly what its header said: merging main turned the suite red naming the two lines to delete. They are deleted. The table is now empty — every ledgered specifier resolves from a packed tarball today.

The empty table is kept, because it and the contract say different things: "every ledgered specifier resolves" is the contract; "the sealed set is exactly empty" is the ratchet — the line an author must edit, past a ⛔, to make a red green by recording a break instead of repairing it.

Non-vacuity — measured, not asserted

Ablation on the final head (a4421eb89), ./console deleted from packages/cli/package.json exports in the worktree, never committed:

  • mutation proven on disk before measuring: "./console" occurrences 1 → 0, git diff HEAD --stat non-empty, resulting map printed.
  • red, 2 failed / 10 passed, naming the consumer:
SEALED: @objectstack/cli/console no longer resolves under the `require` condition.
      This breaks objectstack-ai/cloud — the `objectos-runtime` node server, which mounts
      the Console SPA through resolveConsolePath / hasConsoleDist / createConsoleStaticPlugin
      ledgered:  2026-08-31 (#13662)
      probe:     {"require":{"ok":false,"code":"ERR_PACKAGE_PATH_NOT_EXPORTED"},...}
      Re-open the subpath in the package's exports map, or migrate the consumer and cite
      that migration in the ledger.
  • restore proven by blob hash: git hash-object = 5d0ba78b18fc55d292d161974439b60d7c60bd43 = the HEAD blob, git diff HEAD empty, git status clean.
  • re-run after restore: 12 passed.

The probe returning a real ERR_PACKAGE_PATH_NOT_EXPORTED is what establishes the pipeline ran end to end — pack, unpack, real resolver.

The shape checks are load-bearing for the same reason: the ledger must be non-empty, every ledgered package must declare an exports map (an unsealed package resolves anything and proves nothing), every ledgered package must be a declared dependency, and at least one door must open, so a tarball that failed to unpack cannot read as "all sealed, as recorded". A new check also separates two diagnoses the map cannot tell apart on its own: ERR_PACKAGE_PATH_NOT_EXPORTED is a seal; MODULE_NOT_FOUND is a declared door leading nowhere — unbuilt, or files not shipping what exports names.

CI wiring — measured

The suite does run on PR-side CI for a packages/cli/package.json change. Measured with #15611's own diff pinned as the base and head:

TURBO_SCM_BASE=f99dbcc45 TURBO_SCM_HEAD=95d5cbb31 pnpm exec turbo ls --affected
  -> 7 packages, including @objectstack/downstream-contract

The mechanism is the @objectstack/cli devDependency this PR adds: turbo ls @objectstack/downstream-contract lists @objectstack/cli among its dependencies, and at the merge base the package declared only @objectstack/spec, typescript and vitest, so a cli-only diff could not reach it. Layer B is covered by the same edge — @objectstack/downstream-contract#test declares dependsOn: ["^build"] in turbo.json, so its task hash moves with @objectstack/cli#build and a full-list push or merge-queue run cannot replay a stale cached green.

⚠️ Reported honestly: I tried to prove that edge by ablation and both attempts were confounded, so no clean ablation is claimed. Deleting the manifest entry alone left the edge intact (turbo builds the pnpm workspace graph from pnpm-lock.yaml, which still carried it); deleting the lockfile entry too made the lockfile itself a changed global input, and turbo ls --affected folds in uncommitted working-tree changes, which widened the affected set to 10 rather than removing the package. The claim above rests on turbo's own graph listing and the merge-base manifest, not on a mutation.

No changeset: @objectstack/downstream-contract is "private": true, so it publishes nothing. skip-changeset applied via the additive labels endpoint and read back.

⛔ No file outside packages/qa/downstream-contract/** is edited (plus pnpm-lock.yaml, for that package's own new devDependency). packages/cli/**, root package.json, .github/workflows/** and scripts/check-published-files.mjs are untouched — several are held by other open PRs. check:cross-package-test-inputs passes without a new declaration: OK: 27 package(s) read outside themselves, all declared.

Option B, filed with a measurement — #15715

B would refuse a PR that introduces an exports map unless the changeset is at least minor with a BREAKING-style consumer note. Measured over this repo's history on a4421eb89:

reading count
publishable packages declaring an exports map today 69
distinct commits that introduced one 51
packages born with the map 56
packages retrofitted with one 13
distinct commits behind those 13 7

A package born with a map seals nobody — there is no published predecessor to deep-import it. So B as worded fires on 51 commits to catch a class that lives in 7, roughly 7:1 against, with the false positives clustered on new-package PRs. That is the substance of #15715, which recommends a born-vs-retrofit discriminator over B as literally worded. (The 69 is independently corroborated by check:published-files: "69 declare an exports map".)

Verification — all on final head a4421eb89

  • pnpm --filter '@objectstack/cli...' build — exit 0.
  • new suite — exit 0, Test Files 1 passed (1) / Tests 12 passed (12).
  • whole qa package — exit 0, Test Files 3 passed (3) / Tests 31 passed (31); typecheck exit 0 (its tsconfig include covers test/**/*, so the new file is in the program).
  • derived family, node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack on this head with no stale-tree warning: 85 commands run, 80 exit 0. Verdict lines quoted rather than bare $?; exit codes captured before any pipe:
check-nul-bytes: OK (scanned 7590 text file(s) ... no raw ASCII control bytes).
✓ check:published-files - 69 publishable package(s) of 79 workspace member(s) ... 69 declare
  an `exports` map gating what of that is resolvable (census control: floor 50).
OK: 27 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
✓ dispatch-gates self-test: 1445 cases pass.

NOT MEASURED (5) — none of them a red

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

Committed as-is from a dead session's worktree, before verification, so a
container restart cannot lose it. Verified and rewritten in the commits that
follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…e exports maps

An `exports` map is a PACKAGING contract, and inside this monorepo nothing is
sealed: every in-repo consumer reaches any file through a relative import, a
vitest alias or a `paths` entry. So the repo could not observe a sealing
regression at all, by construction — twice the seal 17.3.0 introduced (#13123)
was found after publish, by the consumer, during an upgrade (#13662, #15325).

`consumer-specifiers.ledger.json` imports that knowledge from outside: the bare
specifiers named out-of-repo consumers import, each with the consumer, a date
and the card that ratified it. The suite packs each ledgered package with
`pnpm pack`, unpacks it into a throwaway `node_modules` outside the workspace,
and resolves every specifier from there with Node's real resolver under both
the `require` and `import` conditions — failing with a message that names the
specifier AND the consumer it breaks.

Option A of #15589 only. Option B (a changeset-gated seal declaration in
`check:published-files`) is deliberately not here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…h doors

The set-equality assertion did exactly what its header said it would. #15611
ratified `./hook-body` and `./package.json` and merged while this branch was in
flight; merging main turned the suite red naming the two lines to delete. They
are deleted, so the ledger of losses has reached its floor: every specifier a
named out-of-repo consumer imports now resolves from a packed tarball.

The empty table is KEPT rather than removed. "Every ledgered specifier resolves"
is the contract; "the sealed set is exactly empty" is the ratchet — the line an
author has to edit, past a ⛔, to make a red green by recording a break instead
of repairing it.

Also separates two diagnoses the exports map cannot tell apart on its own: a
specifier that fails with ERR_PACKAGE_PATH_NOT_EXPORTED is SEALED (the subject
of this file), while one failing MODULE_NOT_FOUND is a declared door leading
nowhere — the package is unbuilt, or `files` does not ship the path `exports`
names. Now that all three doors are open, an unbuilt tree would otherwise have
read as "hotcrm is broken".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/qa/downstream-contract/consumer-specifiers.ledger.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/qa/downstream-contract/consumer-specifiers.ledger.json) — pages documenting those are invisible to this run
  • 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 e13ede81706138c15fb39eab4b4e4e24e3854745packageMentionDocs.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file tests labels Sep 5, 2026
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff at a4421eb89 (4 files, +497/−2), not the report. First line Part of #15589 (option A only; option B is decision card #15715 — the card stays open). No closing keyword adjacent to any other card number.

What the diff does

Measured / arbiter: the test needs a built @objectstack/cli + pnpm pack — NOT MEASURED on this seat. The dev's readings at this head: 12/12 in the new file, 31/31 package-wide, typecheck OK; non-vacuity proven by deleting ./console from the cli exports on disk → 2 red naming cloud objectos-runtime with a real ERR_PACKAGE_PATH_NOT_EXPORTED, restore blob-proven; CI wiring measured (turbo ls --affected on #15611's own diff lists @objectstack/downstream-contract). The dev reports honestly that its ablation of the graph edge was confounded twice (lockfile) and claims no clean reading for it. CI's Lint & Repo Gates + the qa suite are the arbiter.

Fences held: packages/qa/downstream-contract/** + the lockfile only; check-governed-merges.mjs --test0 of 4 path(s) hit the register; no other open PR holds these files; git merge-tree origin/main clean.

Recorded for triage, not decided here: #15715 (option B, with the measured 69 packages / 51 commits / 56 born-sealed / 13 retrofits; the dev recommends B1, born-vs-retrofit). Flip + arm once both required jobs read success.


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review September 5, 2026 04:23
@baozhoutao
baozhoutao enabled auto-merge September 5, 2026 04:24
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 1847594 Sep 5, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-15589-consumer-specifier-ledger branch September 5, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file 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.

2 participants