Skip to content

ci: a published README's symbol claims are checked against the package's built exports - #9546

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-9532-published-readme-export-gate
Aug 18, 2026
Merged

ci: a published README's symbol claims are checked against the package's built exports#9546
os-project-manager merged 2 commits into
mainfrom
claude/issue-9532-published-readme-export-gate

Conversation

@os-project-manager

@os-project-manageros-project-manager commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Part of #9532piece 2 only, the gate. The five per-package README rewrites are the other half of that card and are deliberately untouched here, so the card stays open for them.

Verified at 466fe1394 (the head of this branch; the gate union below was run on that tree).

Premise, re-measured before building

Both of the card's measurements still resolve to zero on origin/main (02ebb6f5b): no static configure anywhere under packages/**/*.ts, and none of ServiceAnalytics / ServiceAutomation / ServiceCache / ServiceJob is exported by any file. The fixture set stands as dispatched.

One correction worth recording: plugin-audit's README only became honest on origin/main (PR #9531). A checkout predating that merge still shows PluginAudit, which src/index.ts has never exported — so the negative control has to be measured against origin/main, not a local main.

What landed

scripts/check-published-readme-exports.mjs resolves every symbol a published markdown document tells a reader to import — through the package's exports map, to the built .d.ts a consumer actually installs — and fails when the symbol is not there.

Two halves:

  • import — a name in an import { … } from clause naming a workspace package must be an export of that package's type entry. This catches all six measured instances.
  • call site — for a name that did resolve, Name.member( must name a real property of that symbol's type. This is where .configure() lives in the worse version of the defect, where the class itself is real.

Type resolution is the TypeScript compiler's, over one shared program — not a regex over declaration text — so cross-package re-exports resolve the way a consumer's compiler resolves them.

The call-site half is not decorative — it was kept on evidence

The dispatch said to ship the import half alone rather than ship something noisy. Both halves shipped, because both measured clean and the second one found defects the first structurally cannot see:

  • packages/mcp/README.md:40MCPServerPluginis a real exported class; its .configure() is invented.
  • packages/objectql/README.md:66SchemaRegistry.registerObject is an instance method called on the class.

It is also what stops a careless rewrite of the five service READMEs. Measured directly: substituting the genuine AnalyticsServicePlugin into service-analytics' README turns the import finding into an AnalyticsServicePlugin.configurecall-site finding — not into silence.

Scope was set by measurement, not taste

The card warned a naive version "will drown in false positives on prose and pseudo-code", and a muted gate is worse than none because it still reads as coverage. Every boundary below was measured on the real tree first:

DecisionThe measurement behind it
Fenced code blocks onlyOf 145 lines across 50 published READMEs carrying both import and an @objectstack/ specifier, 142 are inside fences. All 3 outside are false positives — two markdown links in packages/core ("import @objectstack/spec") and one table row about an admin user import feature.
Code-language fences onlyImport lines appear only under typescript / ts / tsx / diff. bash (51 fences), json (12), sql, sh are excluded; untagged fences are included (zero import lines today, so it is free coverage).
diff fences read the + side onlyThe one diff fence in the tree is a migration table whose - lines document the old imports. Scanning them would report the deletion the fence exists to announce.
CHANGELOG.md excludedIt is in every package's files, but a changelog documents the past: an entry naming a v1 export that v3 removed is correct text this gate would redden permanently, unfixably. Any other published .md is in scope automatically.
Only workspace targetsreact, @objectql/core and friends are not this repo's business.

Result on this tree: 16 findings across 10 files, and zero false positives. Every one was hand-verified against the built .d.ts before it was accepted.

Why the built .d.ts, and why an unbuilt package is an error

The question is "can a consumer write this line?", and a consumer resolves through exports to a .d.ts. Reading src/index.ts would answer a similar-looking question about a file no consumer sees, and would miss a subpath the README imports that exports never declares (which is exactly driver-sql's finding).

That makes it build-dependent, and build-dependent gates fail characteristically: on a fresh checkout dist/ is absent, the scan reads nothing, and green means "not measured" while looking like "measured and clean". So a missing type entry is a hard error with its own heading and remedy, never a skip. The second commit exists only because reverse-verifying that path showed it reporting under the wrong heading with a remedy pointing at the README instead of at pnpm build.

Baseline

All 16 findings are pre-existing on main, so they seed scripts/published-readme-exports.baseline.json:

Self-test and reverse verification

--self-test drives the whole pipeline offline in both directions, over the shapes measured above: the three prose false positives, the bash fence, the diff fence's two sides, multi-line clauses, aliases, import type, both exports-map shapes, and end-to-end analysis of a fabricated import, a fabricated static, and an honest README. A text scanner over prose fails by quietly matching nothing, and only a positive control tells that apart from a clean tree.

Four ablations, each predicted before it was run, each restored afterwards:

AblationPredictedObserved
Delete one baseline entry1 fresh finding1 fresh finding, service-analytics line 27
"Fix" that README, leave the entry1 stale entry and a new call-site findingexactly that — AnalyticsServicePlugin.configure
Restore plugin-audit's README to its pre-#9531 textred on PluginAuditred on PluginAudit; green again on restore
Remove one package's dist/index.d.tshard error naming the buildhard error naming pnpm --filter @objectstack/service-cache build

The third is the strongest control: on the exact bytes of the README that shipped, this gate would have caught #9517 before it went out.

CI wiring

One step added to the existingTypeScript Type Check job in lint.yml, after its build steps, beside the other dist-reading gate (check:api-surface). No new job, so no new required context — nothing waits on a check name no head has ever reported. Confirmed by check:required-contexts, which still pins the same 8 names across 2 workflows.

Gate union — all green at 466fe1394

Derived from the actual changed paths via node scripts/pm/dispatch-gates.mjs (paths taken from git merge-base origin/main HEAD, per #9320), then run:

check:published-readme-exports OK (self-test + 60 docs, 166 imports, 47 type entries)
check:nul-bytes OK
check:ratchet-remedy-authority OK (90 scripts swept; the new one is inside the glob)
check:required-contexts OK (8 pinned names, unchanged)
check:workflow-status-functions OK
check:published-files OK
check:node-version OK
check:shard-attestation OK
check:type-check-coverage OK
check:type-check-debt OK (33 ledger entries re-measured, 224s, none above record)

eslint is clean on the new script. Prettier is not wired into CI and the existing gate scripts fail bare prettier --check identically, so the file follows its siblings' convention.

Changeset

None — new tooling, nothing published moves. Labelled skip-changeset.

Filed, not fixed

Eleven of the 16 findings sit outside this card's five-package rewrite scope. One of them (service-i18n's cross-package useTranslation claim) falls inside #9532's own rewrite of that README, so it needs no card. The other ten are filed unassigned:


Generated by Claude Code

…ackage's built exports (#9532)
Six published service READMEs documented a `.configure()` entry point on
classes that exist nowhere in the repo. #9517 fixed one by hand; a single
grep found five more. Six instances of one defect is a missing gate, not
six mistakes -- nothing read a published README against its package's
exports, so the drift was free in both directions.
check:published-readme-exports resolves every symbol a published markdown
document tells a reader to import, through the `exports` map, to the built
`.d.ts` a consumer installs. Two halves: the import clause, and `X.member(`
call sites on names that import bound (which is where `.configure()` lives
when the class itself is real).
Scope was set by measurement, not taste. Of the 145 lines across 50
published READMEs carrying both `import` and an `@objectstack/` specifier,
the 3 outside code fences are all prose false positives; `diff` fences read
their `+` side only; CHANGELOG.md is excluded because a changelog documents
the past. The result on this tree is 16 findings across 10 files and zero
false positives, each hand-verified.
Those 16 seed a shrink-only baseline reconciled in both directions, so
fixing a README forces its entry to be deleted in the same PR. Every entry
pins one symbol, never a file, and records the real export -- the baseline
is the worklist for the per-package rewrites. plugin-audit is deliberately
absent: it is the negative control.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
Reverse-verifying the missing-dist path showed it reporting under the
"undocumented symbol claim" heading with a remedy telling the reader to fix
the README -- when the actual fix is `pnpm build`. A gate whose failure
sends the reader at the wrong file is one push away from being muted, which
is the failure this whole gate exists to prevent.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
@github-actionsgithub-actionsBot added size/xl ci/cd dependencies Pull requests that update a dependency file labels Aug 18, 2026
@os-project-manageros-project-manager added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed ci/cd dependencies Pull requests that update a dependency file labels Aug 18, 2026 — with Claude
@os-project-manager
os-project-manager marked this pull request as ready for review August 18, 2026 12:03
@os-project-manager
os-project-manager added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 1c6da6eAug 18, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-9532-published-readme-export-gate branch August 18, 2026 12:18
os-project-manager pushed a commit that referenced this pull request Aug 18, 2026
… not a `.configure()` that never existed (#9532)
`service-analytics`, `service-automation`, `service-cache`, `service-i18n` and
`service-job` each told a reader to import a `Service…` class from its own package
and call a static `.configure({...})` on it. Neither has ever existed: no class in
this repo exposes a static `configure`, and none of those five identifiers is
exported by anything. All five READMEs are in their packages' `files` arrays with
`private` unset, so they are the pages npm renders.
Each README is rewritten against the package's BUILT type surface — the entry point
is a kernel plugin constructed with `new` in every case — and each package's entry is
deleted from `scripts/published-readme-exports.baseline.json` in the same commit
(that baseline is reconciled in both directions, so a stale entry fails too):
16 entries -> 10.
A name swap alone would not have gone green, which is the point of the gate landed in
#9546: substituting the genuine class while keeping `.configure(...)` turns the import
finding into a call-site finding rather than into silence.
Also removed as fabricated: nine analytics REST endpoints of which none exists, a
five-endpoint automation REST list matching no mounted route, fourteen `ICacheService`
methods on a six-member contract, an i18n dialect with namespaces/plurals/formatters
over a synchronous `t(key, locale, params?)`, and ten `IJobService` methods on a
three-required-member contract.
Two capability claims are corrected rather than deleted, because the source decides:
`RedisCacheAdapter` throws from every method and `adapter: 'redis'` throws at init, and
`JobServicePlugin`'s `adapter: 'interval'` stores cron registrations that never fire.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xlskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude