From 86f86af8875af34564312c33c22992073024ecab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:39:45 +0000 Subject: [PATCH 1/4] feat(scripts): gate README self-imports against the real export surface Adds `scripts/check-readme-exports.mjs` and `pnpm check:readme-exports`: every name a `packages/**/README.md` imports from its OWN package must be a name that package really exports. The export set is read symbol-level from each package's declared type entry via the TypeScript checker's `getExportsOfModule`, with aliases resolved before the value/type flags are read. The README side extracts fenced code blocks, parses each with `ts.createSourceFile`, and walks `ImportDeclaration` nodes, judging the export name (`propertyName`) rather than the local alias. Verdicts are three-state: real / fabricated / wrong-path. A package whose declared type entry is not on disk is a FAILURE, never a silent skip and never "exports nothing". Fixes the one drift the first run found: `packages/core/src/adapters/README.md` imported `createObjectStackAdapter` and `ObjectStackAdapter` from `@object-ui/core`; both live in `@object-ui/data-objectstack`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .github/workflows/readme-exports.yml | 104 +++ package.json | 1 + packages/core/src/adapters/README.md | 4 +- .../__tests__/check-readme-exports.test.ts | 502 ++++++++++++ .../__tests__/merge-queue-reporting.test.ts | 8 + scripts/check-readme-exports.mjs | 768 ++++++++++++++++++ scripts/dependabot-merge-gate.mjs | 2 + 7 files changed, 1387 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/readme-exports.yml create mode 100644 scripts/__tests__/check-readme-exports.test.ts create mode 100644 scripts/check-readme-exports.mjs diff --git a/.github/workflows/readme-exports.yml b/.github/workflows/readme-exports.yml new file mode 100644 index 0000000000..aa3436510f --- /dev/null +++ b/.github/workflows/readme-exports.yml @@ -0,0 +1,104 @@ +name: README Exports + +# Why this is its own workflow, and not a step in `ci.yml`. +# +# The two changes that introduce this drift are (1) a README edit and (2) a +# source edit that renames or drops an export. `ci.yml` cannot see the first at +# all: every one of its jobs opens with the `id: relevant` short-circuit whose +# diff excludes `**/*.md`, so on a README-only pull request its expensive steps +# are skipped by design (objectui#3523 moved the path filter into the jobs on +# purpose, and `merge-queue-reporting.test.ts` holds it there). A gate against +# fabricated README imports, living behind a switch that skips README-only pull +# requests, would rebuild the hole it exists to close — the conclusion +# `vi-mock-specifiers.yml`, `docs-links.yml`, `control-bytes.yml`, +# `skills-paths.yml` and `changeset-presence.yml` each record in their own +# headers. One gate, one home. +# +# Hence: no `paths` and no `paths-ignore` here, deliberately. +# `scripts/__tests__/check-readme-exports.test.ts` fails if either is added. +# +# ## Why this one pays for an install and a build, when its neighbours do not +# +# The cheap-tier gates above run on a checkout plus one `node` call. This one +# cannot: the export set is read SYMBOL-LEVEL out of each package's declared +# type entry, which for 36 of the 39 packages is a built `dist/index.d.ts`. The +# card (objectui#5043) measured why an approximation is not an option — a bare +# `grep` for `GanttSchema` in `packages/types/src` has six hits, every one of +# them a substring of `ObjectGanttSchema`, so a text-level export set calls a +# fabricated name real. +# +# Measured cost of the extra steps on the tree this landed on: `turbo run build` +# over all 39 packages, cold cache, concurrency 2, on a CONTENDED container: +# 2m42s. That is well inside the same order as `ci.yml`'s own `Type Check` job +# and an order below `Build & E2E`, which is what makes an unfiltered per-PR run +# affordable here where it would not be for a full E2E. +# +# `scripts/dependabot-merge-gate.mjs` classifies `README Export Check` as a +# required context — an unclassified blocking check is one a Dependabot merge +# would be let past (objectui#6135), and since objectui#6160 the `merge_group` +# floor below DERIVES from that same list. + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note + # and the measurements behind it). A required check that does not report on a + # queue build stalls the queue until the ruleset's 60-minute timeout fails it, + # so an unfiltered gate that can become required subscribes here from the + # start. `types:` is named although `checks_requested` is currently the only + # activity type GitHub defines for `merge_group`. + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: readme-exports-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + readme-exports: + name: README Export Check + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + submodules: true + + - name: Enable Corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The gate reads each package's DECLARED type entry. Without this step + # those files do not exist, and the gate FAILS with `unbuilt` rather than + # reporting every README import as fabricated or quietly judging nothing — + # see the "a package whose types are not on disk" section in the script. + - name: Build every package, so the declared type entries exist + run: pnpm exec turbo run build --filter='./packages/*' + + # A README in `packages//` teaching `import { X } from + # '@object-ui/'` for an `X` the package does not export ships in the + # npm tarball and gives the reader TS2305 or a TypeError. One manual sweep + # (objectui#5043) found it in seven packages and recorded that as a LOWER + # bound, because the method could only see single-line imports. + # + # GREEN AT REST — zero drift in the tree when this landed and there should + # stay zero — so it prints its census rather than a bare "OK", and FAILS if + # the population collapses to nothing. + - name: Check every README self-import against the real export surface + run: pnpm check:readme-exports diff --git a/package.json b/package.json index 1697fe87ea..88ac558fe6 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "check:entry-guard": "node scripts/check-entry-guard.mjs", "check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs", "check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs", + "check:readme-exports": "node scripts/check-readme-exports.mjs", "cli": "node packages/cli/dist/cli.js", "objectui": "node packages/cli/dist/cli.js", "create-plugin": "node packages/create-plugin/dist/index.js", diff --git a/packages/core/src/adapters/README.md b/packages/core/src/adapters/README.md index e2befe801b..6011d009fe 100644 --- a/packages/core/src/adapters/README.md +++ b/packages/core/src/adapters/README.md @@ -18,7 +18,7 @@ The `ObjectStackAdapter` provides seamless integration with ObjectStack Protocol ### Usage ```typescript -import { createObjectStackAdapter } from '@object-ui/core'; +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; // Create the adapter const dataSource = createObjectStackAdapter({ @@ -41,7 +41,7 @@ const schema = { ### Advanced Usage ```typescript -import { ObjectStackAdapter } from '@object-ui/core'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; const adapter = new ObjectStackAdapter({ baseUrl: 'https://api.example.com', diff --git a/scripts/__tests__/check-readme-exports.test.ts b/scripts/__tests__/check-readme-exports.test.ts new file mode 100644 index 0000000000..7b0a56ee7b --- /dev/null +++ b/scripts/__tests__/check-readme-exports.test.ts @@ -0,0 +1,502 @@ +/** + * Pin tests for `scripts/check-readme-exports.mjs` (objectui#5043). + * + * ## Why the planted mutations are in here and not in a prose paragraph + * + * The tree is GREEN AT REST: zero fabricated README imports when this landed, + * and the intent is that there stay zero. So the gate's own run proves nothing + * about whether it can FAIL, and "I planted a fake name and it caught it" in a + * pull-request body proves it once and then stops being true. The card recorded + * three method iterations that each looked right and each was measured wrong, + * so the four discriminating directions live below as assertions. + * + * Recall alone is not enough, and that is the whole reason for the second one: + * + * 1. a fabricated name in a MULTI-LINE import block -> must be REPORTED + * 2. a fabricated name in a TRAILING `//` COMMENT -> must NOT be reported + * 3. `X as Y` where X is fabricated -> reported as X + * 4. a fabricated name MID-BLOCK in a type import -> must be REPORTED + * + * (2) is the false positive the second prototype produced; a gate tested only + * for recall passes with it present and reddens correct documentation. + * + * ## And why there is a fixture tree rather than a scan of this repository + * + * The end-to-end verdict needs each package's DECLARED TYPE ENTRY on disk, + * which for almost every package here is a built `dist/index.d.ts`. The test + * shards run `pnpm install` and then `pnpm test` — they never build. A suite + * that scanned this repository for its verdicts would therefore assert nothing + * in CI while passing locally, which is this gate's own defect one directory + * over. So the verdicts are asserted against a fixture tree that carries its + * own hand-written `.d.ts` files, and the assertions about THIS repository + * below are written to hold in both states and to say which one they are in. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; +import { + CODE_LANGS, + FLOORS, + extractCodeBlocks, + findImportBindings, + packageDirOf, + parseReadmeOverrides, + scan, + summarise, + typeEntryOf, +} from '../check-readme-exports.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** A throwaway `packages/` tree, written once and reused by every case below. */ +function fixtureTree(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-readme-exports-')); + for (const [rel, body] of Object.entries(files)) { + const target = path.join(root, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, body); + } + return root; +} + +const manifest = (name: string, types: string | null) => + `${JSON.stringify(types === null ? { name } : { name, exports: { '.': { types } } }, null, 2)}\n`; + +/** + * Alpha's export surface, written the way a real barrel is: the two names a + * consumer sees come through a RE-EXPORT, so both are Alias symbols. That is + * what makes the value/type flags a real assertion rather than a tautology — + * reading flags off the alias reports every one of them as type-only, which is + * what the prototype's first version did. + */ +const FIXTURE = { + 'packages/alpha/package.json': manifest('@fix/alpha', './dist/index.d.ts'), + 'packages/alpha/dist/inner.d.ts': + 'export declare const realValue: number;\n' + 'export interface RealShape { a: string }\n', + 'packages/alpha/dist/index.d.ts': + "export { realValue, RealShape } from './inner.js';\n" + 'export declare function localFn(): void;\n', + 'packages/beta/package.json': manifest('@fix/beta', './dist/index.d.ts'), + 'packages/beta/dist/index.d.ts': 'export declare const ownedByBeta: string;\n', +}; + +const FIXTURE_PACKAGES = ['packages/alpha', 'packages/beta']; + +const scanFixture = (root: string) => + scan(root, { readmes: ['packages/alpha/README.md'], packageDirs: FIXTURE_PACKAGES, floors: {} }); + +const verdicts = (root: string) => + scanFixture(root) + .findings.map((f) => `${f.verdict}:${f.exportName}`) + .sort(); + +/** + * The README shape this whole family uses, and the one that broke the regex: + * a SIDE-EFFECT import, then twenty lines of prose, then a multi-line value + * block with trailing comments, then a multi-line type block. + */ +const readme = (extraValue = '', extraType = '', trailingComment = '') => `# @fix/alpha + +\`\`\`typescript +import '@fix/alpha'; +\`\`\` + +The registration above is the whole of it. There is no manual component map, +no \`alphaComponents\` record, and no \`AlphaSchema\` export to reach for; the +renderer resolves everything from the schema it is handed. Prose in this +paragraph mentions weeks, title, selection, target and dataSource on purpose — +a scan that reads an import clause by regex swallows all of it. + +\`\`\`typescript +import { + realValue, // the exported constant${trailingComment} +${extraValue} localFn, +} from '@fix/alpha'; +\`\`\` + +\`\`\`ts +import type { +${extraType} RealShape, +} from '@fix/alpha'; +\`\`\` +`; + +describe('extractCodeBlocks — the fence rules, because a lost block is a silent gap', () => { + it('reads the info string and reports the opening fence line', () => { + const blocks = extractCodeBlocks('intro\n\n```typescript\nconst a = 1;\n```\n'); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ lang: 'typescript', startLine: 3, terminated: true }); + expect(blocks[0].body).toBe('const a = 1;'); + }); + + it('lets a longer fence CONTAIN a shorter one, as CommonMark specifies', () => { + const blocks = extractCodeBlocks('````md\n```ts\nimport { X } from "y";\n```\n````\n'); + expect(blocks).toHaveLength(1); + expect(blocks[0].lang).toBe('md'); + expect(blocks[0].body).toContain('```ts'); + }); + + it('does not treat an info-carrying fence as a closer', () => { + const blocks = extractCodeBlocks('```ts\na\n```\n\n```ts\nb\n```\n'); + expect(blocks.map((b) => b.body)).toEqual(['a', 'b']); + }); + + it('counts an unterminated fence rather than swallowing the rest of the file', () => { + const blocks = extractCodeBlocks('```ts\nconst a = 1;\n'); + expect(blocks).toHaveLength(1); + expect(blocks[0].terminated).toBe(false); + }); + + it('normalises the language, so `TypeScript` and `ts` are both parsed', () => { + expect(extractCodeBlocks('```TypeScript\na\n```\n')[0].lang).toBe('typescript'); + expect(CODE_LANGS).toContain('typescript'); + expect(CODE_LANGS).toContain('tsx'); + }); +}); + +describe('findImportBindings — the traps the regex approach could not survive', () => { + it('reads a multi-line block as ONE declaration, with a name per specifier', () => { + const found = findImportBindings('import {\n A,\n B,\n} from "pkg";'); + expect(found.map((b) => b.exportName)).toEqual(['A', 'B']); + expect(found.map((b) => b.line)).toEqual([2, 3]); + }); + + it('THE FALSE POSITIVE: a trailing comment can never contribute a name', () => { + const found = findImportBindings('import {\n A, // madeUpName is not real\n} from "pkg";'); + expect(found.map((b) => b.exportName)).toEqual(['A']); + }); + + it('judges the EXPORT name of `A as B`, never the local alias', () => { + const [binding] = findImportBindings('import { madeUp as Real } from "pkg";'); + expect(binding.exportName).toBe('madeUp'); + expect(binding.local).toBe('Real'); + }); + + it('reports a side-effect import as such — no clause means no name to judge', () => { + const [binding] = findImportBindings('import "pkg";'); + expect(binding).toMatchObject({ kind: 'side-effect', exportName: null, specifier: 'pkg' }); + }); + + it('THE ROOT CAUSE: a side-effect import cannot swallow the prose after it', () => { + // The measured regex failure: a lazy quantifier starting at the `from`-less + // import ran to the next `from "pkg"` and reported five words of prose as + // fabricated import names. + const found = findImportBindings( + 'import "pkg";\n\nweeks title selection target dataSource are prose\n\nimport { A } from "pkg";', + ); + expect(found.map((b) => b.exportName)).toEqual([null, 'A']); + }); + + it('separates a namespace import, which names no export', () => { + const [binding] = findImportBindings('import * as ns from "pkg";'); + expect(binding).toMatchObject({ kind: 'namespace', exportName: null }); + }); + + it('carries the type-only flag from the clause and from the specifier', () => { + expect(findImportBindings('import type { A } from "pkg";')[0].typeOnly).toBe(true); + expect(findImportBindings('import { type A } from "pkg";')[0].typeOnly).toBe(true); + expect(findImportBindings('import { A } from "pkg";')[0].typeOnly).toBe(false); + }); + + it('walks a re-export too — `export { X } from` names an export just as an import does', () => { + const [binding] = findImportBindings('export { madeUp as Out } from "pkg";'); + expect(binding).toMatchObject({ exportName: 'madeUp', specifier: 'pkg' }); + }); + + it('parses a tsx block without treating the JSX as a type assertion', () => { + const found = findImportBindings('import { A } from "pkg";\nconst el = ;', { jsx: true }); + expect(found.map((b) => b.exportName)).toEqual(['A']); + }); +}); + +describe('the export surface is symbols, and aliases resolve before the flags are read', () => { + const root = fixtureTree(FIXTURE); + + it('reads every export of the declared type entry, re-exports included', () => { + const result = scanFixture(root); + const alpha = result.packages.find((p) => p.name === '@fix/alpha'); + expect(alpha?.state).toBe('read'); + expect(alpha?.exportCount).toBe(3); + }); + + it('THE ALIAS TRAP: a re-exported VALUE keeps its Value flag', () => { + // `export { realValue } from './inner.js'` is an Alias symbol carrying no + // Value flag of its own. Reading flags off it marks every re-export in the + // repository as type-only — the prototype's first version did exactly that. + const result = scan(root, { + readmes: ['packages/alpha/README.md'], + packageDirs: FIXTURE_PACKAGES, + readmeOverrides: { 'packages/alpha/README.md': writeReadme(root, readme()) }, + floors: {}, + }); + const byName = new Map(result.bindings.map((b) => [b.exportName, b])); + expect(byName.get('realValue')).toMatchObject({ verdict: 'real', alias: true, isValue: true }); + expect(byName.get('RealShape')).toMatchObject({ verdict: 'real', alias: true, isType: true }); + expect(byName.get('localFn')).toMatchObject({ verdict: 'real', alias: false, isValue: true }); + }); + + it('derives the type entry from the package, never assuming `dist/index.d.ts`', () => { + // `@object-ui/test-support` really does point `exports['.'].types` at + // `src/index.ts`; assuming the built path would call it unbuilt. + const entry = typeEntryOf({ exports: { '.': { types: './src/index.ts' } } }, '/pkg'); + expect(entry.declared).toBe('./src/index.ts'); + expect(entry.path).toBe(path.join('/pkg', 'src/index.ts')); + expect(typeEntryOf({ types: './t.d.ts' }, '/pkg').declared).toBe('./t.d.ts'); + expect(typeEntryOf({ name: 'x' }, '/pkg').declared).toBeNull(); + }); + + it('resolves a nested README to the package that publishes it', () => { + expect(packageDirOf(repoRoot, 'packages/types/src/zod/README.md')).toBe('packages/types'); + expect(packageDirOf(repoRoot, 'packages/types/README.md')).toBe('packages/types'); + }); +}); + +/** Writes a README into the fixture's scratch space and returns its path. */ +function writeReadme(root: string, body: string): string { + const at = path.join(root, `readme-${Math.random().toString(36).slice(2)}.md`); + fs.writeFileSync(at, body); + return at; +} + +describe('PLANTED MUTATIONS — the four directions, predicted before they were run', () => { + const root = fixtureTree(FIXTURE); + const run = (body: string) => + scan(root, { + readmes: ['packages/alpha/README.md'], + packageDirs: FIXTURE_PACKAGES, + readmeOverrides: { 'packages/alpha/README.md': writeReadme(root, body) }, + floors: {}, + }); + + it('BASELINE: the unmutated README is clean, so every red below is the mutation', () => { + const result = run(readme()); + expect(result.findings).toEqual([]); + expect(result.census.selfBindings).toBe(3); + expect(result.census.real).toBe(3); + }); + + it('1. a fabricated name in a MULTI-LINE block is REPORTED', () => { + const result = run(readme(' alphaThings,\n')); + expect(result.findings.map((f) => `${f.verdict}:${f.exportName}`)).toEqual(['fabricated:alphaThings']); + }); + + it('2. a fabricated name in a TRAILING COMMENT is NOT reported', () => { + // The direction a recall-only self-test cannot see. + const result = run(readme('', '', ' — alphaComponents was never real')); + expect(result.findings).toEqual([]); + expect(result.census.real).toBe(3); + }); + + it('3. `X as Y` with X fabricated is reported as X, the EXPORT name', () => { + const result = run(readme(' alphaThings as Things,\n')); + expect(result.findings.map((f) => f.exportName)).toEqual(['alphaThings']); + expect(result.findings.map((f) => f.local)).toEqual(['Things']); + }); + + it('4. a fabricated name MID-BLOCK in a multi-line TYPE import is REPORTED', () => { + const result = run(readme('', ' AlphaSchema,\n')); + expect(result.findings.map((f) => `${f.verdict}:${f.exportName}`)).toEqual(['fabricated:AlphaSchema']); + expect(result.findings[0].typeOnly).toBe(true); + }); + + it('5. a REAL name owned by another package is WRONG-PATH, not fabricated', () => { + // objectui#5010's `CalendarViewSchema`: the fix is the import path, and + // telling the reader it is fabricated tells them to delete a real symbol. + const result = run(readme(' ownedByBeta,\n')); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ verdict: 'wrong-path', exportName: 'ownedByBeta' }); + expect(result.findings[0].owners).toEqual(['@fix/beta']); + }); + + it('names the README LINE of the specifier, not of the `import {` above it', () => { + const result = run(readme(' alphaThings,\n')); + const body = readme(' alphaThings,\n').split('\n'); + expect(body[result.findings[0].line - 1]).toContain('alphaThings'); + }); + + it('all four mutations at once are reported at once — no first-finding short circuit', () => { + const result = run(readme(' alphaThings as Things,\n ownedByBeta,\n', ' AlphaSchema,\n', ' — nope')); + expect(result.findings.map((f) => `${f.verdict}:${f.exportName}`).sort()).toEqual([ + 'fabricated:AlphaSchema', + 'fabricated:alphaThings', + 'wrong-path:ownedByBeta', + ]); + }); +}); + +describe('a package whose types are not on disk FAILS — it never reads as "exports nothing"', () => { + it('reports `unbuilt`, not a wall of fabricated names', () => { + const root = fixtureTree({ + 'packages/alpha/package.json': manifest('@fix/alpha', './dist/index.d.ts'), + 'packages/alpha/README.md': '```ts\nimport { realValue } from "@fix/alpha";\n```\n', + }); + const result = scan(root, { readmes: ['packages/alpha/README.md'], packageDirs: ['packages/alpha'], floors: {} }); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ verdict: 'unjudgeable', reason: 'unbuilt' }); + expect(result.census.fabricated).toBe(0); + expect(result.census.packagesUnbuilt).toBe(1); + }); + + it('reports `no-type-entry` for a package that publishes no types at all', () => { + const root = fixtureTree({ + 'packages/alpha/package.json': manifest('@fix/alpha', null), + 'packages/alpha/README.md': '```ts\nimport { realValue } from "@fix/alpha";\n```\n', + }); + const result = scan(root, { readmes: ['packages/alpha/README.md'], packageDirs: ['packages/alpha'], floors: {} }); + expect(result.findings[0]).toMatchObject({ verdict: 'unjudgeable', reason: 'no-type-entry' }); + }); + + it('is SILENT about an unbuilt package whose README imports nothing from it', () => { + // The failure is scoped to the case where the missing exports would have + // changed a verdict; the count still appears in the census either way. + const root = fixtureTree({ + 'packages/alpha/package.json': manifest('@fix/alpha', './dist/index.d.ts'), + 'packages/alpha/README.md': '```ts\nimport { X } from "@other/pkg";\n```\n', + }); + const result = scan(root, { readmes: ['packages/alpha/README.md'], packageDirs: ['packages/alpha'], floors: {} }); + expect(result.findings).toEqual([]); + expect(result.census.packagesUnbuilt).toBe(1); + }); +}); + +describe('non-vacuity — the population refuses to collapse', () => { + it('declares a floor for every counter a collapse would zero', () => { + expect(Object.keys(FLOORS).sort()).toEqual( + ['codeBlocks', 'exportSymbols', 'importBindings', 'packagesRead', 'readmes', 'selfBindings'].sort(), + ); + }); + + it('reports every floor as breached when BOTH walks return nothing', () => { + const result = scan(repoRoot, { readmes: [], packageDirs: [] }); + expect(result.vacuous.map((v) => v.counter).sort()).toEqual(Object.keys(FLOORS).sort()); + for (const v of result.vacuous) expect(v.value).toBe(0); + }); + + it('breaches the README-side floors alone when only that walk collapses', () => { + // The two walks fail independently -- a broken README pathspec and an + // unbuilt workspace are different accidents, and the verdict has to name + // which one happened rather than printing a uniform wall. + const result = scan(repoRoot, { readmes: [] }); + expect(result.vacuous.map((v) => v.counter).sort()).toEqual( + ['codeBlocks', 'importBindings', 'readmes', 'selfBindings'].sort(), + ); + expect(result.census.packagesRead).toBeGreaterThanOrEqual(FLOORS.packagesRead); + }); + + it('puts the census in the verdict line, so a reader sees the population', () => { + const line = summarise(scan(repoRoot, { readmes: [] })); + expect(line).toContain('README(s) under packages/'); + expect(line).toContain('self-imports judged'); + expect(line).toContain('export symbol(s) read from'); + }); +}); + +describe('repo state — assertions that hold whether or not the tree is built', () => { + const result = scan(repoRoot); + const built = result.census.packagesUnbuilt === 0; + + it('walked the tree: READMEs, fenced blocks and import bindings were all found', () => { + // These three need no `dist/`, so they assert in the test shards too. + expect(result.census.readmes).toBeGreaterThanOrEqual(FLOORS.readmes); + expect(result.census.codeBlocks).toBeGreaterThanOrEqual(FLOORS.codeBlocks); + expect(result.census.importBindings).toBeGreaterThanOrEqual(FLOORS.importBindings); + expect(result.census.readmesOrphaned).toBe(0); + }); + + it('finds no fabricated or wrong-path import when built, and refuses to pass when not', () => { + // `pnpm test` never builds, so in CI this suite takes the second branch; + // locally, after a build, it takes the first. BOTH branches assert — a + // conditional that let the unbuilt tree through silently would be this + // gate's own defect. + const judged = result.findings.filter((f) => f.verdict !== 'unjudgeable'); + if (built) { + expect(judged, `unexpected README drift: ${JSON.stringify(judged, null, 2)}`).toEqual([]); + expect(result.census.selfBindings).toBeGreaterThanOrEqual(FLOORS.selfBindings); + expect(result.census.exportSymbols).toBeGreaterThanOrEqual(FLOORS.exportSymbols); + expect(result.vacuous).toEqual([]); + } else { + expect(judged).toEqual([]); + expect( + result.findings.length + result.vacuous.length, + 'on an unbuilt tree the gate must FAIL (unjudgeable self-imports and/or a breached floor), never report OK', + ).toBeGreaterThan(0); + } + }); + + it('judges the packages the card named, once the tree is built', () => { + if (!built) { + expect(result.census.packagesUnbuilt).toBeGreaterThan(0); + return; + } + const judgedIn = [ + ...new Set( + result.bindings.filter((b) => b.verdict === 'real').map((b) => b.file.split('/').slice(0, 2).join('/')), + ), + ]; + // The seven packages the manual sweep hit (objectui#5010-#5016). + for (const pkg of ['plugin-calendar', 'plugin-form', 'plugin-gantt', 'plugin-grid', 'plugin-view', 'plugin-dashboard', 'plugin-report']) { + expect(judgedIn, `${pkg}'s README is no longer being judged`).toContain(`packages/${pkg}`); + } + }); +}); + +describe('the --readme override, which is what keeps the self-test off the working tree', () => { + it('parses a `=` pair', () => { + expect(parseReadmeOverrides(['--readme', 'packages/a/README.md=/tmp/x.md'])).toEqual({ + 'packages/a/README.md': '/tmp/x.md', + }); + }); + + it('refuses a bare path rather than guessing which README it replaces', () => { + expect(() => parseReadmeOverrides(['--readme', '/tmp/x.md'])).toThrow(/readmePath/); + }); +}); + +describe('wiring — the gate is reachable and every pull-request shape starts it', () => { + const workflowDir = path.join(repoRoot, '.github/workflows'); + const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); + const workflow = fs.readFileSync(path.join(workflowDir, 'readme-exports.yml'), 'utf8'); + const uncommented = workflow + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); + + it('is runnable by name', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(pkg.scripts['check:readme-exports']).toBe('node scripts/check-readme-exports.mjs'); + }); + + it('is run by EXACTLY ONE workflow — two homes is two answers', () => { + const runners = workflowFiles.filter((file) => + fs.readFileSync(path.join(workflowDir, file), 'utf8').includes('check:readme-exports'), + ); + expect(runners).toEqual(['readme-exports.yml']); + }); + + it('carries NO trigger-level path filter — a README-only PR must start it', () => { + // This is the shape `ci.yml` structurally cannot see, which is the whole + // reason this gate has its own workflow. + expect(uncommented).not.toMatch(/^\s*paths(-ignore)?:/m); + }); + + it('subscribes pull_request, push, merge_group and workflow_dispatch', () => { + for (const trigger of ['pull_request:', 'push:', 'merge_group:', 'workflow_dispatch:']) { + expect(uncommented).toContain(trigger); + } + }); + + it('is classified as a required context, so a Dependabot merge waits for it', () => { + expect(REQUIRED_CONTEXTS).toContain('README Export Check'); + expect(uncommented).toContain('name: README Export Check'); + }); + + it('builds before it judges — without `dist/` the gate can only report `unbuilt`', () => { + const buildAt = uncommented.indexOf('turbo run build'); + const checkAt = uncommented.indexOf('pnpm check:readme-exports'); + expect(buildAt).toBeGreaterThan(-1); + expect(checkAt).toBeGreaterThan(buildAt); + expect(uncommented).toContain('pnpm install --frozen-lockfile'); + }); +}); diff --git a/scripts/__tests__/merge-queue-reporting.test.ts b/scripts/__tests__/merge-queue-reporting.test.ts index 806330802e..6a5658ce04 100644 --- a/scripts/__tests__/merge-queue-reporting.test.ts +++ b/scripts/__tests__/merge-queue-reporting.test.ts @@ -107,6 +107,14 @@ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ 'one node call, so it carries no path filter, reports on every pull request, and is ' + 'requirable; `scripts/dependabot-merge-gate.mjs` already classifies it as a required context', ], + [ + 'readme-exports.yml', + 'produces README Export Check — added by objectui#5043. A fabricated self-import can be ' + + 'written into any package README, and a README-only pull request is the shape `ci.yml` ' + + 'structurally cannot see (its jobs short-circuit on a diff that excludes `**/*.md`), so ' + + 'this gate carries no path filter, reports on every pull request, and is requirable; ' + + '`scripts/dependabot-merge-gate.mjs` already classifies it as a required context', + ], ]); /** diff --git a/scripts/check-readme-exports.mjs b/scripts/check-readme-exports.mjs new file mode 100644 index 0000000000..894a9659e0 --- /dev/null +++ b/scripts/check-readme-exports.mjs @@ -0,0 +1,768 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every name a `packages//README.md` imports FROM ITS OWN PACKAGE must be a + * name that package really exports. + * + * Run: node scripts/check-readme-exports.mjs (also `pnpm check:readme-exports`) + * node scripts/check-readme-exports.mjs --list # every self-binding judged + * node scripts/check-readme-exports.mjs --json + * node scripts/check-readme-exports.mjs --readme packages/plugin-gantt/README.md=/tmp/x.md + * Exit: 0 = every self-import names a real export, 1 = a fabricated name, a + * wrong-path name, a package that cannot be judged, or a collapsed scan. + * + * ## The defect (objectui#5043, the root cause of the #5010-#5016 family) + * + * A README in `packages//` teaches `import { X } from '@object-ui/'`. + * Nothing checked that `` exports `X`. `check-doc-links.mjs` parses links + * and never looks inside a code block; `check-doc-component-types.mjs` scans + * `content/docs` and never enters `packages//README.md`. So a name could be + * invented, or survive the export that once backed it being renamed, and every + * gate in the repository stayed green. + * + * These READMEs are listed in each package's `files`, so they ship in the npm + * tarball. A reader who copies the snippet gets a `TypeError` at runtime or a + * TS2305/TS2724 at build time. One manual sweep found drift in SEVEN packages + * (#5010 calendar, #5011 form, gantt, grid, view, #5015 dashboard, #5016 + * report), and the person who did it recorded that number as a LOWER BOUND: the + * method they had could only see single-line import statements. + * + * ## Why this is an AST walk and not a regex (measured, objectui#5043) + * + * The card's original sketch was one cross-line regex, comments stripped, split + * on commas. Run on `plugin-gantt` it reported FIVE names that are not imports + * at all -- `weeks`, `title`, `selection`, `target`, `dataSource` -- and MISSED + * both real fabrications. The root cause is a shape this whole family of + * READMEs uses: a SIDE-EFFECT import (`import '@object-ui/plugin-gantt';`, no + * `from`). A lazy quantifier starting there runs on to the next + * `from '@object-ui/plugin-gantt'` twenty lines later and swallows the prose + * and a schema literal in between as an import clause. Stripping comments and + * splitting on commas then happens to CONTAMINATED text, so words out of the + * prose are reported as fabricated import names. + * + * Extracting fenced blocks and handing each to `ts.createSourceFile` does not + * defend against those traps -- it makes them unrepresentable: + * + * - a multi-line import block is ONE `ImportDeclaration` node, so no fence + * and no paragraph can end up inside an import clause; + * - a trailing `//` comment is trivia and can never contribute a name, which + * is the false positive the second prototype produced; + * - `A as B` gives `propertyName = A` and `name = B`, so the EXPORT name is + * available separately from the local alias. This gate judges + * `propertyName` -- `import { madeUp as Real }` is a fabrication of + * `madeUp`, and reporting it as `Real` would send the reader to the wrong + * word. + * + * ## Why the export set is symbols, and never a grep + * + * Same card, measured: `GanttSchema` grepped against `packages/types/src` has + * six hits, so a grep-based check calls it real. All six are substrings of + * `ObjectGanttSchema`; `\bGanttSchema\b` has zero. So the export set here comes + * from the TypeScript checker's `getExportsOfModule` over the package's own + * declared type entry -- the exact set a consumer's editor resolves. + * + * ALIASES ARE RESOLVED BEFORE THE VALUE/TYPE FLAGS ARE READ. `export { Foo }` + * from a barrel is an Alias symbol that does NOT itself carry the Value flag, + * so reading flags off the alias marks every re-export in the repo as + * type-only. The prototype's first version did exactly that. The flags are a + * census-and-hint field here rather than a verdict (a `import { T }` of a + * type-only export is legal TypeScript), but a hint that lies is worse than no + * hint, so the resolution is done properly and pinned by the test suite. + * + * ## Three states, because two of them have different fixes + * + * real the package exports that name. + * fabricated no package in the workspace exports it. Delete or rename it. + * wrong-path the name is real but belongs to ANOTHER package. #5010's + * `CalendarViewSchema` is this: it lives in `@object-ui/types`, + * and the fix is the import PATH, not the name. Collapsing it + * into "fabricated" tells the reader to delete a correct symbol. + * + * All three are reported; `fabricated` and `wrong-path` both FAIL. + * + * ## A package whose types are not on disk is a FAILURE, never a skip + * + * The export set is read from the package's declared type entry + * (`exports['.'].types`, else `types`/`typings`), which for almost every + * package here is a BUILT `dist/index.d.ts`. Two ways that file can be absent, + * and both directions of silence are wrong: + * + * - treating a missing entry as "this package exports nothing" makes every + * import in its README read as fabricated -- a wall of false reds that + * ends with the gate being deleted; + * - skipping it shrinks the judged population invisibly, which is the same + * defect this gate exists to close, one level up: a scan that quietly + * stops looking still prints a green. + * + * So a package is recorded in one of four states, all of them in the census: + * `read` type entry declared and present -> exports read. + * `unbuilt` type entry declared, file absent -> run the build. + * `no-type-entry` package declares no types at all (an app bundle, an + * extension). Its README cannot teach a named import. + * `no-readme` nothing to judge. + * `unbuilt` and `no-type-entry` FAIL if -- and only if -- that package's README + * actually carries a self-binding, which is the only case where the missing + * exports would have changed a verdict. Either way the count is printed. + * + * ## Non-vacuity + * + * The tree is expected to be GREEN AT REST, so on an ordinary day this gate's + * output is indistinguishable from a gate that does nothing -- which is the + * defect it exists to catch. `FLOORS` turns a collapsed walk (no READMEs, no + * import declarations, no export symbols) into a FAILURE, and the verdict line + * carries the census rather than a bare OK. Same discipline as + * `scripts/check-vi-mock-specifiers.mjs` (objectui#5646). + * + * ## Deliberately out of scope + * + * Extracting the code blocks and COMPILING them (objectui#5043's "stronger + * tier", with the bidirectional pins for documented `interface` blocks) is a + * separate card: the entry price is a batch of pre-existing reds that need a + * baseline decision first. This gate answers one question -- does the imported + * NAME exist -- and says so rather than implying more. + * + * Also invisible to it, and documented on the card: authorable-JSON KEY + * surfaces. `BaseSchema` carries an index signature and its Zod mirror is + * `.passthrough()`, so no amount of type checking rejects an invented schema + * key. That needs a third instrument, not a wider version of this one. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { isEntrypoint } from './invoked-as.mjs'; + +/** Fence info strings whose body is parsed as TypeScript/JavaScript. */ +export const CODE_LANGS = Object.freeze([ + 'ts', + 'tsx', + 'typescript', + 'typescriptreact', + 'mts', + 'cts', + 'js', + 'jsx', + 'javascript', + 'mjs', + 'cjs', +]); + +const LANG_SET = new Set(CODE_LANGS); + +/** Info strings that are JSX-flavoured, so the block is parsed as TSX. */ +const JSX_LANGS = new Set(['tsx', 'jsx', 'typescriptreact']); + +/** + * Floors below which a green verdict is a claim about coverage rather than a + * statement about the tree. Set with room: the point is to catch a walk that + * COLLAPSED, not to pin today's numbers, which move with every package added. + */ +export const FLOORS = Object.freeze({ + readmes: 25, + codeBlocks: 150, + importBindings: 100, + selfBindings: 40, + packagesRead: 25, + exportSymbols: 400, +}); + +/** The NUL that `git ls-files -z` delimits with, built from its code point. */ +const NUL = String.fromCharCode(0); + +/** + * Every tracked `README.md` under `packages/`, package-root and nested alike. + * + * The card scoped this at "packages//README.md". Written as a git + * pathspec that reads as exactly that, it ALSO matches four nested ones -- + * git's default pathspec is fnmatch without FNM_PATHNAME, so `*` crosses `/`. + * Rather than tighten the glob and lose them, they are kept and resolved to + * their OWNING package (`packageDirOf`): `packages/types/src/zod/README.md` + * teaching `import { X } from '@object-ui/types'` is the same defect, and + * `packages/types` ships its whole `src/` in the tarball. The filter is done + * here in JS so the population is a stated rule rather than an accident of + * which pathspec magic was in force. + */ +function trackedReadmes(root) { + return execFileSync('git', ['ls-files', '-z', '--', 'packages/'], { + cwd: root, + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }) + .toString('utf8') + .split(NUL) + .filter((file) => file.endsWith('/README.md')) + .sort(); +} + +/** + * Every `packages//` that carries a manifest. + * + * Enumerated in its OWN right and not derived from the README walk. The + * wrong-path verdict needs to know which package owns a name, and a package + * with no README would otherwise never have its exports read -- so a README + * naming one of ITS symbols would be reported as `fabricated`, sending the + * reader to delete a real export instead of correcting a path. Every package + * here happens to have a README today, which is exactly why the difference + * would have gone unnoticed; the fixture suite is what surfaced it. + */ +function trackedPackages(root) { + return execFileSync('git', ['ls-files', '-z', '--', 'packages/'], { + cwd: root, + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }) + .toString('utf8') + .split(NUL) + .filter((file) => /^packages\/[^/]+\/package\.json$/.test(file)) + .map(dirname) + .sort(); +} + +/** + * The package directory a README belongs to: the nearest ancestor carrying a + * `package.json` with a `name`, never deeper than `packages/`. Derived so + * a nested README is judged against the package that actually publishes it. + */ +export function packageDirOf(root, readmePath) { + let dir = dirname(readmePath); + while (dir.startsWith('packages/') && dir !== 'packages') { + const manifest = readJson(join(root, dir, 'package.json')); + if (manifest?.name) return dir; + dir = dirname(dir); + } + return null; +} + +/** + * Every fenced block in a markdown document. + * + * Written against the fence rules the CommonMark spec actually states, because + * the shortcuts are what let a scan silently lose blocks: a closing fence must + * be at least as long as the opening one (so a ```` ```` ```` block may CONTAIN + * a ``` line), and it carries no info string. An unterminated fence is counted + * rather than dropped -- it is a README bug in its own right, and a scan that + * swallowed the rest of the file would report fewer imports with no sign why. + * + * @returns {{ lang: string, startLine: number, body: string, terminated: boolean }[]} + * `startLine` is the 1-based line of the OPENING fence, so a node at body + * line L sits on README line `startLine + L`. + */ +export function extractCodeBlocks(markdown) { + const lines = markdown.split('\n'); + const blocks = []; + let open = null; + const FENCE = /^(\s{0,3})(`{3,}|~{3,})(.*)$/; + + for (let i = 0; i < lines.length; i++) { + const m = FENCE.exec(lines[i]); + if (open === null) { + if (m) open = { char: m[2][0], len: m[2].length, info: m[3].trim(), startLine: i + 1, body: [] }; + continue; + } + const closes = m && m[2][0] === open.char && m[2].length >= open.len && m[3].trim() === ''; + if (closes) { + blocks.push({ lang: open.info.split(/\s+/)[0].toLowerCase(), startLine: open.startLine, body: open.body.join('\n'), terminated: true }); + open = null; + continue; + } + open.body.push(lines[i]); + } + if (open !== null) { + blocks.push({ lang: open.info.split(/\s+/)[0].toLowerCase(), startLine: open.startLine, body: open.body.join('\n'), terminated: false }); + } + return blocks; +} + +/** + * Every import binding one code block declares, as the AST reports them. + * + * `ImportDeclaration` and a re-exporting `ExportDeclaration` are both walked: + * `export { X } from '@object-ui/pkg'` names an export of that package exactly + * as an import does, and a README that re-exports a name it invented is the + * same defect. + * + * `kind` separates what can be judged from what cannot: + * `named` a named binding -- `exportName` is the name to judge. + * `default` a default import; judged against the `default` export. + * `namespace` `* as X`; the whole module, no name to judge. + * `side-effect` no clause at all. THIS is the shape that broke the regex + * approach, so it is counted explicitly rather than ignored. + * + * @returns {{ specifier: string, kind: string, exportName: string | null, + * local: string | null, typeOnly: boolean, line: number }[]} + * `line` is 1-based WITHIN the block body. + */ +export function findImportBindings(body, { jsx = false } = {}) { + const source = ts.createSourceFile( + jsx ? 'block.tsx' : 'block.ts', + body, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + jsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + const out = []; + const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + + // The line is taken from the NARROWEST node that carries the binding, not + // from the declaration. A 14-name multi-line import is one node; reporting + // the declaration's line for all fourteen sends the reader to `import {` and + // makes them find the offending name themselves. + const push = (node, specifier, kind, exportName, local, typeOnly) => { + out.push({ specifier, kind, exportName, local, typeOnly, line: lineOf(node) }); + }; + + const readNamed = (specifier, elements, clauseTypeOnly) => { + for (const element of elements) { + // `A as B` -> propertyName = A (the EXPORT name), name = B (the alias). + // Judging B would name the reader's own variable, not the package's. + const exportName = element.propertyName ? element.propertyName.text : element.name.text; + push(element, specifier, 'named', exportName, element.name.text, clauseTypeOnly || Boolean(element.isTypeOnly)); + } + }; + + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement)) { + const specifierNode = statement.moduleSpecifier; + if (!ts.isStringLiteral(specifierNode)) continue; + const specifier = specifierNode.text; + const clause = statement.importClause; + if (!clause) { + push(statement, specifier, 'side-effect', null, null, false); + continue; + } + const clauseTypeOnly = Boolean(clause.isTypeOnly); + if (clause.name) push(clause.name, specifier, 'default', 'default', clause.name.text, clauseTypeOnly); + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) { + push(bindings.name, specifier, 'namespace', null, bindings.name.text, clauseTypeOnly); + } else if (bindings && ts.isNamedImports(bindings)) { + readNamed(specifier, bindings.elements, clauseTypeOnly); + } + continue; + } + + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + const specifierNode = statement.moduleSpecifier; + if (!ts.isStringLiteral(specifierNode)) continue; + const specifier = specifierNode.text; + const clauseTypeOnly = Boolean(statement.isTypeOnly); + const clause = statement.exportClause; + if (clause && ts.isNamedExports(clause)) { + readNamed(specifier, clause.elements, clauseTypeOnly); + } else { + // `export * from '…'` / `export * as ns from '…'` -- the whole module. + push(statement, specifier, 'namespace', null, clause && ts.isNamespaceExport(clause) ? clause.name.text : null, clauseTypeOnly); + } + } + } + + return out; +} + +/** + * The type entry a consumer of this package resolves, as the package itself + * declares it. Derived, never assumed to be `dist/index.d.ts`: `test-support` + * points its `exports['.'].types` straight at `src/index.ts`, and reading that + * one correctly is the difference between a real judgement and a false red. + * + * @returns {{ declared: string | null, path: string | null }} + */ +export function typeEntryOf(packageJson, packageDir) { + const dot = packageJson?.exports?.['.']; + const fromExports = + typeof dot === 'object' && dot !== null + ? dot.types ?? dot.import?.types ?? dot.require?.types ?? dot.default?.types + : null; + const declared = fromExports ?? packageJson?.types ?? packageJson?.typings ?? null; + if (typeof declared !== 'string') return { declared: null, path: null }; + return { declared, path: resolve(packageDir, declared) }; +} + +function isFile(p) { + try { + return statSync(p).isFile(); + } catch { + return false; + } +} + +/** Compiler options for the export-surface program. */ +const PROGRAM_OPTIONS = Object.freeze({ + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX, + allowJs: true, + skipLibCheck: true, + noEmit: true, + strict: false, + resolveJsonModule: true, + allowSyntheticDefaultImports: true, + esModuleInterop: true, +}); + +/** + * `entryPath -> Map`, from ONE program + * over every entry at once (the packages import each other, so a program per + * package would re-read the same declaration files 39 times). + */ +export function readExportSurfaces(entryPaths) { + const surfaces = new Map(); + if (entryPaths.length === 0) return surfaces; + + const program = ts.createProgram({ rootNames: [...entryPaths], options: { ...PROGRAM_OPTIONS } }); + const checker = program.getTypeChecker(); + + for (const entry of entryPaths) { + const names = new Map(); + surfaces.set(entry, names); + const sourceFile = program.getSourceFile(entry); + if (!sourceFile) continue; + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) continue; + + for (const symbol of checker.getExportsOfModule(moduleSymbol)) { + // Resolve the alias FIRST. `export { Foo } from './foo'` is an Alias + // symbol with no Value flag of its own; reading flags off it marks every + // re-export as type-only, which is what the prototype's first version did. + const alias = Boolean(symbol.flags & ts.SymbolFlags.Alias); + let resolved = symbol; + if (alias) { + try { + resolved = checker.getAliasedSymbol(symbol) ?? symbol; + } catch { + resolved = symbol; + } + } + names.set(symbol.name, { + isValue: Boolean(resolved.flags & ts.SymbolFlags.Value), + isType: Boolean(resolved.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.Interface)), + alias, + }); + } + } + return surfaces; +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +/** + * The one scan. `main()`, `--list`, `--json` and the test suite all go through + * here, so the tests exercise the real code path rather than an imitation. + * + * @param {string} root Repository root. + * @param {{ readmes?: string[] | null, + * packageDirs?: string[] | null, + * readmeOverrides?: Record, + * floors?: Record }} [options] + * `readmes` and `packageDirs` override the two `git ls-files` walks. `readmeOverrides` maps a + * README path (`packages/plugin-gantt/README.md`) to a file that stands in + * for it -- which is how the planted-mutation self-test runs without ever + * writing to the working tree. `floors` overrides `FLOORS`; pass `{}` to + * switch the collapse check off for a fixture tree. + */ +export function scan(root, { readmes = null, packageDirs = null, readmeOverrides = {}, floors = FLOORS } = {}) { + const readmeFiles = readmes ?? trackedReadmes(root); + + // 1. Every package, its declared type entry, and its state. The whole + // workspace, not only the packages that happen to carry a README -- + // see `trackedPackages`. + const packages = new Map(); // packageDir -> record + for (const packageDir of packageDirs ?? trackedPackages(root)) { + const manifest = readJson(join(root, packageDir, 'package.json')); + const { declared, path } = typeEntryOf(manifest, join(root, packageDir)); + packages.set(packageDir, { + dir: packageDir, + name: manifest?.name ?? null, + declaredEntry: declared, + entryPath: path, + state: declared === null ? 'no-type-entry' : isFile(path) ? 'read' : 'unbuilt', + readmes: [], + selfBindings: 0, + }); + } + + const ownerOf = new Map(); // readme path -> packageDir + const orphans = []; + for (const readme of readmeFiles) { + const packageDir = packageDirOf(root, readme); + if (packageDir === null || !packages.has(packageDir)) { + orphans.push(readme); + continue; + } + ownerOf.set(readme, packageDir); + packages.get(packageDir).readmes.push(readme); + } + + // 2. One program over every entry that is actually on disk. + const entryPaths = [...packages.values()].filter((p) => p.state === 'read').map((p) => p.entryPath); + const surfaces = readExportSurfaces(entryPaths); + + /** `exportName -> package names that export it`, for the wrong-path verdict. */ + const nameOwners = new Map(); + let exportSymbols = 0; + for (const record of packages.values()) { + if (record.state !== 'read') continue; + const names = surfaces.get(record.entryPath) ?? new Map(); + record.exportCount = names.size; + exportSymbols += names.size; + for (const name of names.keys()) { + if (!nameOwners.has(name)) nameOwners.set(name, []); + nameOwners.get(name).push(record.name ?? record.dir); + } + } + + // 3. Walk each README. + const bindings = []; + const findings = []; + const counters = { + codeBlocks: 0, + codeBlocksParsed: 0, + codeBlocksUntagged: 0, + codeBlocksUnterminated: 0, + importBindings: 0, + selfBindings: 0, + sideEffect: 0, + namespace: 0, + deepSelf: 0, + external: 0, + real: 0, + fabricated: 0, + wrongPath: 0, + }; + + for (const readme of readmeFiles) { + const record = packages.get(ownerOf.get(readme)); + if (!record) continue; // an orphan README -- counted, never judged + const override = readmeOverrides[readme]; + const onDisk = override ?? join(root, readme); + let markdown; + try { + markdown = readFileSync(onDisk, 'utf8'); + } catch { + continue; + } + + for (const block of extractCodeBlocks(markdown)) { + counters.codeBlocks++; + if (!block.terminated) counters.codeBlocksUnterminated++; + if (block.lang === '') counters.codeBlocksUntagged++; + if (!LANG_SET.has(block.lang)) continue; + counters.codeBlocksParsed++; + + for (const binding of findImportBindings(block.body, { jsx: JSX_LANGS.has(block.lang) })) { + // `...binding` FIRST: it carries its own block-relative `line`, and + // spreading it last silently overwrote the README line number with it. + const site = { ...binding, file: readme, line: block.startLine + binding.line, package: record.name }; + + counters.importBindings++; + if (binding.kind === 'side-effect') counters.sideEffect++; + if (binding.kind === 'namespace') counters.namespace++; + + const isSelf = record.name !== null && binding.specifier === record.name; + const isDeepSelf = record.name !== null && binding.specifier.startsWith(`${record.name}/`); + if (isDeepSelf) counters.deepSelf++; + if (!isSelf) { + if (!isDeepSelf) counters.external++; + bindings.push({ ...site, verdict: 'not-self' }); + continue; + } + if (binding.exportName === null) { + bindings.push({ ...site, verdict: 'no-name' }); + continue; + } + + counters.selfBindings++; + record.selfBindings++; + + // Every finding carries the SAME key set, `owners`/`reason` included. + // Three differently-shaped literals here infer as a union, and the + // pin tests then cannot read a field without narrowing first. + const finding = (verdict, extra) => ({ + ...site, + verdict, + owners: [], + reason: null, + declaredEntry: record.declaredEntry, + ...extra, + }); + + if (record.state !== 'read') { + bindings.push({ ...site, verdict: 'unjudgeable' }); + findings.push(finding('unjudgeable', { reason: record.state })); + continue; + } + + const names = surfaces.get(record.entryPath) ?? new Map(); + const hit = names.get(binding.exportName); + if (hit) { + counters.real++; + bindings.push({ ...site, verdict: 'real', ...hit }); + continue; + } + const owners = (nameOwners.get(binding.exportName) ?? []).filter((owner) => owner !== record.name); + if (owners.length > 0) { + counters.wrongPath++; + bindings.push({ ...site, verdict: 'wrong-path', owners }); + findings.push(finding('wrong-path', { owners })); + } else { + counters.fabricated++; + bindings.push({ ...site, verdict: 'fabricated' }); + findings.push(finding('fabricated')); + } + } + } + } + + const states = { read: 0, unbuilt: 0, 'no-type-entry': 0 }; + for (const record of packages.values()) states[record.state]++; + + const census = { + readmes: readmeFiles.length, + readmesOrphaned: orphans.length, + packages: packages.size, + packagesWithReadme: [...packages.values()].filter((p) => p.readmes.length > 0).length, + packagesRead: states.read, + packagesUnbuilt: states.unbuilt, + packagesNoTypeEntry: states['no-type-entry'], + exportSymbols, + ...counters, + }; + + const vacuous = []; + for (const [counter, floor] of Object.entries(floors)) { + if (census[counter] < floor) vacuous.push({ counter, value: census[counter], floor }); + } + + return { census, packages: [...packages.values()], orphans, bindings, findings, vacuous }; +} + +function repoRoot() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +/** The census, as one line, for the verdict. */ +export function summarise({ census }) { + return ( + `${census.readmes} README(s) under packages/ (${census.readmesOrphaned} outside any package), ` + + `${census.codeBlocks} fenced block(s) ` + + `(${census.codeBlocksParsed} parsed as code, ${census.codeBlocksUntagged} untagged); ` + + `${census.importBindings} import binding(s), ${census.selfBindings} of them self-imports judged ` + + `(${census.real} real, ${census.wrongPath} wrong-path, ${census.fabricated} fabricated); ` + + `${census.exportSymbols} export symbol(s) read from ${census.packagesRead} of ${census.packages} package(s) ` + + `(${census.packagesWithReadme} carry a README) ` + + `(${census.packagesUnbuilt} unbuilt, ${census.packagesNoTypeEntry} declare no types); ` + + `${census.sideEffect} side-effect import(s), ${census.namespace} namespace, ${census.deepSelf} deep self-path, ` + + `${census.external} to other packages` + ); +} + +/** Parses `--readme packages/x/README.md=/path/to.md` (repeatable) out of argv. */ +export function parseReadmeOverrides(argv) { + const overrides = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i] !== '--readme') continue; + const [dir, ...rest] = (argv[i + 1] ?? '').split('='); + if (!dir || rest.length === 0) { + throw new Error('--readme needs `=`, e.g. --readme packages/plugin-gantt/README.md=/tmp/x.md'); + } + overrides[dir] = rest.join('='); + } + return overrides; +} + +function main(overrides) { + const result = scan(repoRoot(), { readmeOverrides: overrides }); + const { findings, vacuous } = result; + + if (findings.length === 0 && vacuous.length === 0) { + console.log(`✅ check-readme-exports: OK (${summarise(result)}).`); + process.exit(0); + } + + const fabricated = findings.filter((f) => f.verdict === 'fabricated'); + const wrongPath = findings.filter((f) => f.verdict === 'wrong-path'); + const unjudgeable = findings.filter((f) => f.verdict === 'unjudgeable'); + + if (fabricated.length > 0) { + console.error(`❌ check-readme-exports: ${fabricated.length} README import name(s) NO package exports\n`); + console.error(' These READMEs ship in the npm tarball. A reader who copies the snippet'); + console.error(' gets TS2305/TS2724 at build time or a TypeError at runtime:\n'); + for (const f of fabricated) { + console.error(` - ${f.file}:${f.line} -- import { ${f.exportName} } from '${f.specifier}'`); + } + console.error('\n Fix the README, or export the name. The judged name is the EXPORT name:'); + console.error(" in `import { madeUp as Real }` the fabrication is `madeUp`.\n"); + } + + if (wrongPath.length > 0) { + console.error(`❌ check-readme-exports: ${wrongPath.length} README import name(s) belong to ANOTHER package\n`); + console.error(' The name is real. The PATH is wrong -- do not delete the symbol:\n'); + for (const f of wrongPath) { + console.error(` - ${f.file}:${f.line} -- '${f.exportName}' is exported by ${f.owners.join(', ')}, not by ${f.package}`); + } + console.error(''); + } + + if (unjudgeable.length > 0) { + console.error(`❌ check-readme-exports: ${unjudgeable.length} self-import(s) could not be judged\n`); + for (const f of unjudgeable) { + const why = + f.reason === 'unbuilt' + ? `its type entry \`${f.declaredEntry}\` is not on disk -- run \`pnpm build\` first` + : 'its package declares no `types` entry at all, so it publishes no named exports to import'; + console.error(` - ${f.file}:${f.line} -- ${f.package} imports '${f.exportName}', but ${why}`); + } + console.error(` +This is a FAILURE and not a skip on purpose. Counting a missing type entry as +"exports nothing" would mark every one of these fabricated; skipping it would +shrink the judged population with nothing in the output to say so. Both are the +defect this gate exists to catch, one level up. +`); + } + + if (vacuous.length > 0) { + console.error('\n❌ check-readme-exports: the population COLLAPSED -- this run proves nothing\n'); + for (const v of vacuous) { + console.error(` - ${v.counter}: found ${v.value}, floor is ${v.floor}`); + } + console.error(` +A scan that finds nothing reports OK, and reads as coverage. Something upstream +of the judgement broke: \`git ls-files\` returned little or nothing, the fence +extraction stopped matching, or the packages were never built. Fix the walk. If +a floor is genuinely too high because the tree changed shape, move it in +\`FLOORS\` deliberately and say why -- never to make a red run green. +`); + } + + console.error(`Census: ${summarise(result)}`); + process.exit(1); +} + +if (isEntrypoint(import.meta.url)) { + const overrides = parseReadmeOverrides(process.argv.slice(2)); + if (process.argv.includes('--json')) { + const result = scan(repoRoot(), { readmeOverrides: overrides }); + console.log(JSON.stringify({ census: result.census, findings: result.findings, vacuous: result.vacuous }, null, 2)); + } else if (process.argv.includes('--list')) { + const result = scan(repoRoot(), { readmeOverrides: overrides }); + for (const b of result.bindings) { + if (b.verdict === 'not-self') continue; + const mark = b.verdict.padEnd(11); + console.log(`${mark} ${b.file}:${b.line} ${b.exportName ?? `(${b.kind})`} <- ${b.specifier}`); + } + console.log(`\n${summarise(result)}`); + } else { + main(overrides); + } +} diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 5f5de3a605..958686cb8e 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -128,6 +128,7 @@ import { isEntrypoint } from './invoked-as.mjs'; * doc-fence-languages.yml Doc Fence Language Check * pre-install-import-graph.yml Pre-Install Import Graph Check * vi-mock-specifiers.yml Inert vi.mock Specifier Check + * readme-exports.yml README Export Check * * The four shards are spelled out individually on purpose. A single `Test` * entry, or any pattern match, would be satisfied by whichever shard happened @@ -152,6 +153,7 @@ export const REQUIRED_CONTEXTS = Object.freeze([ 'Doc Fence Language Check', 'Pre-Install Import Graph Check', 'Inert vi.mock Specifier Check', + 'README Export Check', ]); /** From 2db373fab86b9b3f55eb2dbb9881922a1aca0f3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:48:53 +0000 Subject: [PATCH 2/4] docs(ci): document the README Export Check in the pipeline guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/__tests__/ci-cd-pipeline-doc.test.ts` requires every workflow in `.github/workflows/` to have its own section and an inventory row — a tooling diff is not automatically a tooling-only diff. Adds both, plus the changeset for the published README fix, and drops an unused test helper the linter flagged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .changeset/5043-readme-export-drift.md | 21 ++++++ content/docs/guide/ci-cd-pipeline.md | 66 +++++++++++++++++++ .../__tests__/check-readme-exports.test.ts | 5 -- 3 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 .changeset/5043-readme-export-drift.md diff --git a/.changeset/5043-readme-export-drift.md b/.changeset/5043-readme-export-drift.md new file mode 100644 index 0000000000..fe8232a18e --- /dev/null +++ b/.changeset/5043-readme-export-drift.md @@ -0,0 +1,21 @@ +--- +'@object-ui/core': patch +--- + +`packages/core/src/adapters/README.md` no longer teaches an import that does not exist. +Both of its snippets read `import { createObjectStackAdapter } from '@object-ui/core'` and +`import { ObjectStackAdapter } from '@object-ui/core'`; neither symbol is exported by +`@object-ui/core` — both live in `@object-ui/data-objectstack`, whose own JSDoc example +spells the correct path. The adapter moved out of `packages/core/src/adapters/` and the +README that documents it was left behind pointing at the old home, so a reader who copied +either snippet got TS2305 at build time. The name was right; the path was wrong, so the +path is what changed. + +This README is inside `packages/core`, which ships its `src/` in the npm tarball, so the +wrong import shipped to consumers rather than staying an internal note. + +Found by `pnpm check:readme-exports` (objectui#5043) on its first run over the tree — the +gate added in the same change, which reads each package's real export surface out of its +declared type entry with the TypeScript checker and fails on any README self-import naming +something the package does not export. Nothing else in this change is published: the gate, +its pin tests and its workflow are repository tooling. diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 6e70bb68ea..1fa3cabd25 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -35,6 +35,7 @@ one has its own section below. | `doc-fence-languages.yml` | Doc Fence Language Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a TypeScript block sits under a fence the snippet gate does not read | | `pre-install-import-graph.yml` | Pre-Install Import Graph Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a gate a workflow runs *before* `pnpm install` reaches a package anywhere in its import graph | | `vi-mock-specifiers.yml` | Inert vi.mock Specifier Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `vi.mock` / `vi.doMock` relative specifier resolves to no file, or the scan's population collapses | +| `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | @@ -831,6 +832,71 @@ that the suite goes red. Run it locally with `pnpm check:vi-mock-specifiers`, or `node scripts/check-vi-mock-specifiers.mjs --list` to see every call site the walk found. It needs no install and no build. +## README Exports (`readme-exports.yml`) + +**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no +path filter at all**. It appears in the checks list as **README Export Check**. + +The absent filter is the point. The two edits that introduce this drift are a README change and a +source change that renames or drops an export, and `ci.yml` structurally cannot see the first: every +one of its jobs opens with the `id: relevant` short-circuit whose diff excludes `**/*.md`, so on a +README-only pull request its expensive steps are skipped by design. A gate against fabricated README +imports living behind that switch would rebuild the hole it exists to close. + +Runs `scripts/check-readme-exports.mjs` (`pnpm check:readme-exports`). For every `README.md` under +`packages/`, it extracts the fenced code blocks, parses each one with the TypeScript parser, walks the +`ImportDeclaration` nodes, and for every binding that names the README's **own** package checks the +name against that package's real export surface. + +**Why it needed a gate.** A README teaching `import { X } from '@object-ui/'` for an `X` the +package does not export gave the reader a `TypeError` at runtime or a TS2305/TS2724 at build time, and +these READMEs are listed in each package's `files`, so they ship in the npm tarball. Nothing checked +them: `check-doc-links.mjs` parses links and never looks inside a code block, and +`check-doc-component-types.mjs` scans `content/docs` and never enters `packages/`. One manual sweep +([#5043](https://github.com/objectstack-ai/objectui/issues/5043)) found drift in **seven** packages +(#5010–#5016) and recorded that number as a *lower bound*, because the method it used could only see +single-line import statements. + +**It parses, it does not match.** The card's first sketch was a cross-line regex; measured on +`plugin-gantt` it reported five words of prose as fabricated import names and missed both real +fabrications, because a **side-effect import** (`import '@object-ui/plugin-gantt';`, no `from`) lets a +lazy quantifier run on to the next `from` twenty lines later. Parsing makes that unrepresentable: a +multi-line block is one node, a trailing `//` comment is trivia that can never contribute a name, and +`A as B` exposes the export name separately from the local alias — the gate judges **`A`**. + +**The export set is symbols, never a grep.** It comes from the TypeScript checker's +`getExportsOfModule` over each package's *declared* type entry, with aliases resolved before the +value/type flags are read. A text-level set is measurably wrong here: `GanttSchema` grepped in +`packages/types/src` has six hits, every one of them a substring of `ObjectGanttSchema`. + +**Three verdicts, because two of them have different fixes.** `real`; `fabricated` (no package exports +it — delete or rename); and `wrong-path` (the name is real but belongs to another package, so the +*path* is what to change). #5010's `CalendarViewSchema` was the third kind, and the first run of this +gate found one more: `packages/core/src/adapters/README.md` imported `ObjectStackAdapter` and +`createObjectStackAdapter` from `@object-ui/core` when both live in `@object-ui/data-objectstack`. + +**It builds first, and refuses to guess when it cannot.** The declared type entry is a built +`dist/index.d.ts` for almost every package, so the workflow installs and runs `turbo run build` before +the check (measured cold, concurrency 2, on a contended container: 2m42s for all 39 packages). If a +package's type entry is missing anyway, that is a **failure**, never a skip: counting it as "exports +nothing" would mark every import in its README fabricated, and skipping it would shrink the judged +population with nothing in the output to say so. + +**It is green at rest, so its census is part of the verdict** — READMEs scanned, blocks parsed, +bindings judged, packages whose exports were read — and the scan **fails when that population +collapses**. The evidence that it can fail lives in `scripts/__tests__/check-readme-exports.test.ts`, +which plants four mutations on a fixture tree: a fabricated name in a multi-line block, one in a +trailing comment (which must **not** be reported), an `X as Y` with `X` fabricated, and one mid-block +in a type import. + +**Out of scope, deliberately:** compiling the extracted blocks (a separate card — it has pre-existing +reds that need a baseline decision first), and authorable-JSON *key* surfaces, which no type check can +reject while `BaseSchema` carries an index signature and its Zod mirror is `.passthrough()`. + +**If it fails:** it names the README, the line of the offending specifier, and which package really +exports the name. Run it locally with `pnpm check:readme-exports` after a build, or +`node scripts/check-readme-exports.mjs --list` to see every self-import it judged. + ## Link Checking (`check-links.yml`) **Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run diff --git a/scripts/__tests__/check-readme-exports.test.ts b/scripts/__tests__/check-readme-exports.test.ts index 7b0a56ee7b..b2227a4324 100644 --- a/scripts/__tests__/check-readme-exports.test.ts +++ b/scripts/__tests__/check-readme-exports.test.ts @@ -87,11 +87,6 @@ const FIXTURE_PACKAGES = ['packages/alpha', 'packages/beta']; const scanFixture = (root: string) => scan(root, { readmes: ['packages/alpha/README.md'], packageDirs: FIXTURE_PACKAGES, floors: {} }); -const verdicts = (root: string) => - scanFixture(root) - .findings.map((f) => `${f.verdict}:${f.exportName}`) - .sort(); - /** * The README shape this whole family uses, and the one that broke the regex: * a SIDE-EFFECT import, then twenty lines of prose, then a multi-line value From 68c3a07bf42f583ea596e014728e5885060506a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 02:11:33 +0000 Subject: [PATCH 3/4] test(scripts): scan the fixture tree for the two-walks independence claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `breaches the README-side floors alone when only that walk collapses` called `scan(repoRoot, { readmes: [] })`, so its verdict depended on whether the checkout was built. The claim needs the PACKAGE-side walk healthy while the README walk collapses; against `repoRoot` that precondition is a property of the machine. The test shards run `pnpm install` then `pnpm test` and never build, so on CI every `packages/*` type entry is absent, `packagesRead` and `exportSymbols` breach too, and the exact equality failed — green locally, red in `Test (shard 2/4)`. Moved onto the same fixture tree the rest of the suite uses, with fixture-scale floors, and split into three: a control leg asserting nothing breaches while both walks are healthy, the collapse leg keeping the exact equality, and a leg pinning that the package-side counters are unchanged by the collapse. The equality stays exact on purpose — a containment check would pass on a built tree and an unbuilt one alike, asserting that four counters breached without asserting that the other two did not, which is the whole independence claim. Also narrows `puts the census in the verdict line` to override both walks: it asserts the line's SHAPE, never a count, so it should read nothing off disk. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .../__tests__/check-readme-exports.test.ts | 74 ++++++++++++++++--- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/scripts/__tests__/check-readme-exports.test.ts b/scripts/__tests__/check-readme-exports.test.ts index b2227a4324..2df8e3c1d5 100644 --- a/scripts/__tests__/check-readme-exports.test.ts +++ b/scripts/__tests__/check-readme-exports.test.ts @@ -369,19 +369,73 @@ describe('non-vacuity — the population refuses to collapse', () => { for (const v of result.vacuous) expect(v.value).toBe(0); }); - it('breaches the README-side floors alone when only that walk collapses', () => { - // The two walks fail independently -- a broken README pathspec and an - // unbuilt workspace are different accidents, and the verdict has to name - // which one happened rather than printing a uniform wall. - const result = scan(repoRoot, { readmes: [] }); - expect(result.vacuous.map((v) => v.counter).sort()).toEqual( - ['codeBlocks', 'importBindings', 'readmes', 'selfBindings'].sort(), - ); - expect(result.census.packagesRead).toBeGreaterThanOrEqual(FLOORS.packagesRead); + describe('the two walks breach INDEPENDENTLY', () => { + // On the FIXTURE tree, not `repoRoot`, and that is the whole point of this + // block. The claim is "collapse the README walk and only the README-side + // floors breach", which needs the package-side walk to be HEALTHY so that + // collapsing `readmes` is the only variable. Against `repoRoot` that + // precondition is a property of the machine: the fixture carries + // hand-written `.d.ts` files, while the test shards run `pnpm install` and + // then `pnpm test` and never build, so on CI every `packages/*` type entry + // is absent and the package-side floors breach too. Written against + // `repoRoot` this test passed on a built checkout and RED on CI -- + // measured, on this branch's first CI run. + // + // The exact equality is deliberate and must stay exact. Loosening it to a + // containment check would pass on a built tree AND an unbuilt one, which + // is precisely the distinction this test exists to draw; it would assert + // that these four breached without asserting that those two did not, and + // the independence claim would be gone while the test still read green. + const root = fixtureTree(FIXTURE); + const at = writeReadme(root, readme()); + + /** Fixture-scale floors: the repo's own numbers are three orders too big. */ + const floors = { + readmes: 1, + codeBlocks: 1, + importBindings: 1, + selfBindings: 1, + packagesRead: 2, + exportSymbols: 3, + }; + + const healthy = scan(root, { + readmes: ['packages/alpha/README.md'], + packageDirs: FIXTURE_PACKAGES, + readmeOverrides: { 'packages/alpha/README.md': at }, + floors, + }); + const collapsed = scan(root, { readmes: [], packageDirs: FIXTURE_PACKAGES, floors }); + + it('breaches nothing while BOTH walks are healthy — the control leg', () => { + // Without this, a green below could mean "the fixture is broken too". + expect(healthy.vacuous).toEqual([]); + expect(healthy.census.packagesRead).toBe(2); + expect(healthy.census.selfBindings).toBe(3); + }); + + it('breaches the README-side floors ALONE when only that walk collapses', () => { + expect(collapsed.vacuous.map((v) => v.counter).sort()).toEqual( + ['codeBlocks', 'importBindings', 'readmes', 'selfBindings'].sort(), + ); + for (const v of collapsed.vacuous) expect(v.value).toBe(0); + }); + + it('leaves the package-side counters untouched by that collapse', () => { + // The other half of "independently": these two are read from the same + // scan and are unchanged from the healthy leg. + expect(collapsed.census.packagesRead).toBe(healthy.census.packagesRead); + expect(collapsed.census.exportSymbols).toBe(healthy.census.exportSymbols); + expect(collapsed.census.packagesRead).toBe(2); + expect(collapsed.census.exportSymbols).toBe(4); + }); }); it('puts the census in the verdict line, so a reader sees the population', () => { - const line = summarise(scan(repoRoot, { readmes: [] })); + // Both walks overridden to empty, so this reads NOTHING off disk and its + // verdict cannot depend on whether the checkout is built. It asserts the + // SHAPE of the line, never a count. + const line = summarise(scan(repoRoot, { readmes: [], packageDirs: [] })); expect(line).toContain('README(s) under packages/'); expect(line).toContain('self-imports judged'); expect(line).toContain('export symbol(s) read from'); From c9496f043eb62756b73ff6fe1ea49cf8325c5893 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 03:25:00 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20drop=20the=20core=20REA?= =?UTF-8?q?DME=20changeset=20=E2=80=94=20that=20fix=20is=20no=20longer=20i?= =?UTF-8?q?n=20this=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@object-ui/core` patch changeset described the two repaired import paths in `packages/core/src/adapters/README.md`. #6227 (a26b9e4f8) has since rewritten that page wholesale — the ObjectStack half is deleted and its material moved to `@object-ui/data-objectstack` — so this branch resolved that file to #6227's version and now contributes nothing to it. What remains here is `scripts/`, `.github/workflows/`, the docs-site guide page and the private root manifest: zero published source of any released package. `check-changeset-presence.mjs` confirms it — "0 of them published source of a package the release covers ... no changeset is owed." Keeping it would have published a release note for a fix this diff does not contain, and double-counted a `@object-ui/core` patch that #6227's own changeset already carries. Note that the presence gate is green either way, so this is a judgement it cannot make. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .changeset/5043-readme-export-drift.md | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .changeset/5043-readme-export-drift.md diff --git a/.changeset/5043-readme-export-drift.md b/.changeset/5043-readme-export-drift.md deleted file mode 100644 index fe8232a18e..0000000000 --- a/.changeset/5043-readme-export-drift.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@object-ui/core': patch ---- - -`packages/core/src/adapters/README.md` no longer teaches an import that does not exist. -Both of its snippets read `import { createObjectStackAdapter } from '@object-ui/core'` and -`import { ObjectStackAdapter } from '@object-ui/core'`; neither symbol is exported by -`@object-ui/core` — both live in `@object-ui/data-objectstack`, whose own JSDoc example -spells the correct path. The adapter moved out of `packages/core/src/adapters/` and the -README that documents it was left behind pointing at the old home, so a reader who copied -either snippet got TS2305 at build time. The name was right; the path was wrong, so the -path is what changed. - -This README is inside `packages/core`, which ships its `src/` in the npm tarball, so the -wrong import shipped to consumers rather than staying an internal note. - -Found by `pnpm check:readme-exports` (objectui#5043) on its first run over the tree — the -gate added in the same change, which reads each package's real export surface out of its -declared type entry with the TypeScript checker and fails on any README self-import naming -something the package does not export. Nothing else in this change is published: the gate, -its pin tests and its workflow are repository tooling.