diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index a8c26e1fcdb..f9058dc7569 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -399,6 +399,10 @@ import { readClause2Line, readMetadataProtocolLine } from './pm/clause2-line.mjs // this gate into a sandbox (`objectui-changeset-digest.mjs`), because two hand // lists for one graph is how one of them goes stale unnoticed. import { stageFirstPartyClosure } from './first-party-closure.mjs'; +// #26220 — the workspace's package globs, through the repo's ONE parse of +// `pnpm-workspace.yaml` rather than a hand-kept list of roots (see +// `workspaceGlobsAt`). +import { WORKSPACE_FILE, WorkspaceEnumerationError, isExclusionGlob, parseWorkspaceGlobs } from './workspace-enumerator.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -457,6 +461,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'REX-N: the predicate set is pinned BY NAME, never by count': 3, 'REX-U: unit pins on the operand grammar and the FROM-side reader': 24, 'REX-L: the compiler is loaded only when a marker names the category': 6, + 'WS (#26220): ui workspace packages, `.tsx` modules, aliases and shims': 11, 'G6: a changeset that was ALREADY breaking at base is inherited': 1, 'R15: a changeset RENAMED AND turned breaking in the same commit': 5, 'G10: a PURE rename of an ALREADY-breaking stock changeset': 3, @@ -489,7 +494,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 55; +const SELF_TEST_BATTERY_FLOOR = 56; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -533,8 +538,6 @@ export const MIGRATION_LEDGER_ENTRY_DIRS = [ const WITNESS_GENERATOR = 'packages/spec/scripts/build-spec-changes.ts'; const WITNESS_INPUTS = ['packages/spec/src', 'packages/spec/scripts', 'packages/spec/package.json']; -/** Package roots holding workspace manifests, for the `unpublished` precondition. */ -const PACKAGE_ROOTS = ['packages', 'apps', 'examples']; /** * The closed disposition vocabulary. Adding a category is a deliberate act. @@ -2663,21 +2666,82 @@ export function assertInputs({ cwd, head, witness = generatedWitness }) { // Workspace manifests -- the `unpublished` precondition // --------------------------------------------------------------------------- +/** + * The workspace's package globs at a rev -- `pnpm-workspace.yaml` read from git and + * parsed by `workspace-enumerator.mjs`, the repo's one parse of it (#26220). + * + * This gate used to carry its own `PACKAGE_ROOTS = ['packages', 'apps', 'examples']`. + * That list predates the objectui import, so every `@object-ui/*` package (the + * `ui/packages/*`, `ui/apps/*` and `ui/examples/*` globs) had no workspace manifest + * here, and every disposition that reads one -- `unpublished`, `type-surface-only` + * predicate 1, `runtime-export-retired` predicate 1 -- refused a true claim about + * them. A hand-kept copy of the workspace's membership is the same drift #14378 + * found in a different file; the workspace's own declaration cannot drift from + * itself. Read at the REV being judged, never from the working tree, so the gate + * stays a pure function of its two revs. + * + * A missing file, an empty or duplicate `packages:` block and a glob richer than + * `` / `/*` are REFUSED with `WorkspaceEnumerationError`, never read as an + * empty workspace: every disposition above would then answer "no workspace + * manifest" about a package that has one. + * + * @returns {string[]} the inclusion globs; an exclusion (`!pattern`) enumerates nothing + */ +export function workspaceGlobsAt(rev, cwd) { + const source = `${WORKSPACE_FILE} at ${rev.slice(0, 12)}`; + const text = showOrNull(rev, WORKSPACE_FILE, cwd); + if (text === null) { + throw new WorkspaceEnumerationError( + `${source} is missing -- the workspace's package globs cannot be read, so no package can be\n` + + ' matched to its manifest. Refused rather than read as an empty workspace (#4690).', + ); + } + const globs = parseWorkspaceGlobs(text, { source }).filter((g) => !isExclusionGlob(g)); + for (const g of globs) { + if (g.replace(/\/\*$/, '').includes('*')) { + throw new WorkspaceEnumerationError( + `${source}: pattern ${JSON.stringify(g)} is richer than \`\` or \`/*\`, the two shapes\n` + + ' `workspace-enumerator.mjs` expands. Teach both readers the new shape in one edit;\n' + + ' matching fewer manifests would refuse true claims about the packages left out.', + ); + } + } + return globs; +} + +/** + * Is `manifest` (a repo-relative `/package.json`) a workspace member's + * manifest under `globs`? `` names that directory; `/*` names each + * directory directly under it -- the expansion `expandWorkspaceGlob` performs on a + * working tree, applied here to a path listed from a rev. + */ +export function isWorkspaceManifest(manifest, globs) { + if (!manifest.endsWith('/package.json') || manifest.includes('node_modules/')) return false; + const dir = manifest.slice(0, -'/package.json'.length); + const parent = posix.dirname(dir); + const leaf = posix.basename(dir); + return globs.some((g) => (g.endsWith('/*') + ? parent === g.slice(0, -2) && !leaf.startsWith('.') + : dir === g)); +} + /** * name -> { private, file } for every workspace package at a rev. * * Read from git rather than the working tree so the whole gate is a pure function * of two revs, which is what lets the self-test drive the shipping code path with - * real temp repositories instead of an imitation of it. + * real temp repositories instead of an imitation of it. The members are the ones + * the rev's own `pnpm-workspace.yaml` declares (`workspaceGlobsAt`). */ export function workspacePackagesAt(rev, cwd) { const pkgs = new Map(); + const globs = workspaceGlobsAt(rev, cwd); let out; try { out = git(['ls-tree', '-r', '--name-only', rev], cwd); } catch { return pkgs; } const manifests = out .split('\n') .map((s) => s.trim()) - .filter((p) => p.endsWith('/package.json') && !p.includes('node_modules/') && PACKAGE_ROOTS.some((r) => p.startsWith(`${r}/`))); + .filter((p) => isWorkspaceManifest(p, globs)); for (const [p, raw] of showManyOrNull(rev, manifests, cwd)) { try { const json = JSON.parse(raw); @@ -4537,6 +4601,7 @@ function readExportSurface(rev, cwd, pkgName, pkgs, kit) { const fromVirtual = (f) => (f.startsWith(VIRTUAL_ROOT) ? f.slice(VIRTUAL_ROOT.length) : null); const options = { noEmit: true, skipLibCheck: true, types: [], lib: ['lib.es2022.d.ts'], + jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, }; const real = ts.createCompilerHost(options, true); @@ -5587,7 +5652,16 @@ function selfTest() { '# ADR-0087 (fixture)\n\n' + cats.map((c) => `\n`).join(''); - const build = ({ baseIds = ['old-entry-one', 'old-entry-two'], headIds = null, files = {}, baseFiles = {}, pkgs = null }) => { + // The fixture's `pnpm-workspace.yaml` (#26220): the real file's glob shapes, + // `ui/packages/*` among them, so a fixture package is a member exactly when it + // would be one in this repository. `workspace: null` writes none. + const WORKSPACE_YAML = + 'packages:\n - packages/*\n - packages/apps/*\n - packages/drivers/*\n - packages/plugins/*\n' + + ' - packages/qa/*\n - packages/triggers/*\n - packages/services/*\n - packages/adapters/*\n' + + ' - packages/connectors/*\n - apps/*\n - examples/*\n' + + ' # the objectui tree\n - ui\n - ui/packages/*\n - ui/apps/*\n - ui/examples/*\n'; + + const build = ({ baseIds = ['old-entry-one', 'old-entry-two'], headIds = null, files = {}, baseFiles = {}, pkgs = null, workspace = WORKSPACE_YAML }) => { const dir = mkdtempSync(join(tmpdir(), 'adr0087-')); const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); @@ -5601,6 +5675,7 @@ function selfTest() { w(LEDGER_SOURCES[1], CONV(['a-conversion'])); w(WITNESS_GENERATOR, WITNESS_GEN()); w(ADR_0087, ADR_DOC()); + if (workspace !== null) w(WORKSPACE_FILE, workspace); // stock: one declared-breaking changeset -- realistic mid-cycle stock for // the inherited-changeset cases. (No longer needed to satisfy assertInputs: // since #8658 its convention-rot control is synthetic and phase-independent.) @@ -7533,6 +7608,108 @@ function selfTest() { 'REX-L5: a scan with the reader loaded, or with no such marker, never asks for it'); } + // ---- WS (#26220): a ui workspace package, read through the workspace file -- + // + // The gate's package globs are the rev's own `pnpm-workspace.yaml`, so an + // `@object-ui/*` package under `ui/packages/*` has a manifest here like any + // other. And its export reader compiles `.tsx`: the ui entries re-export from + // `.tsx` modules -- directly, through an alias, and through a `.ts` shim -- and + // without the `jsx` option every such module failed to resolve (TS6142), so + // predicate 2 could not locate a single declaration behind one. + battery('WS (#26220): ui workspace packages, `.tsx` modules, aliases and shims'); + { + const UI_PKG = '@object-ui/app-shell'; + const UI_DIR = 'ui/packages/app-shell'; + const uiPath = (rel) => `${UI_DIR}/${rel}`; + const UI_PKGS = { + '@objectstack/spec': { dir: 'packages/spec', private: false }, + '@objectstack/platform-objects': { dir: 'packages/platform-objects', private: false }, + '@object-ui/console': { dir: 'ui/apps/console', private: true }, + }; + const UI_INDEX_HEAD = "export { AppShell } from './AppShell.js';\n"; + const UI_BASE_FILES = { + [uiPath('package.json')]: JSON.stringify({ + name: UI_PKG, version: '1.0.0', types: 'dist/index.d.ts', + exports: { '.': { types: './dist/index.d.ts', import: './dist/index.mjs' } }, + }), + [uiPath('src/index.ts')]: + `${UI_INDEX_HEAD}` + + "export { useExpressionContext } from './providers/ExpressionProvider.js';\n" + + "export { PageShell as MetadataPageShell } from './views/PageShell.js';\n" + + "export type { RecentItemInput } from './hooks/useRecentItems.js';\n", + [uiPath('src/AppShell.tsx')]: 'export const AppShell = () =>
;\n', + [uiPath('src/providers/ExpressionProvider.tsx')]: + 'export function useExpressionContext() {\n return {1};\n}\n', + [uiPath('src/views/PageShell.tsx')]: 'export function PageShell() {\n return
;\n}\n', + [uiPath('src/hooks/useRecentItems.ts')]: + "export { useRecentItems, type RecentItemInput } from '../context/RecentItemsProvider.js';\n", + [uiPath('src/context/RecentItemsProvider.tsx')]: + 'export interface RecentItemInput { id: string }\nexport function useRecentItems() {\n return
    ;\n}\n', + 'packages/platform-objects/src/audit/sys-email-template.object.ts': + "export const SysEmailTemplate = { name: 'sys_email_template' };\n", + }; + const UI_OPERANDS = ['useExpressionContext', 'MetadataPageShell', 'RecentItemInput']; + const UI_CS = CS({ + bumps: [[UI_PKG, 'patch']], + body: + 'refactor(app-shell)!: retire three root-entry exports\n\n**BREAKING**: retired exports.\n\n' + + UI_OPERANDS.map((o) => `- FROM \`${o}\` TO nothing: delete the import.`).join('\n') + + `\n\n\n', + }); + const UI_FIXTURE = (over = {}) => ({ + pkgs: UI_PKGS, + baseFiles: UI_BASE_FILES, + files: { [uiPath('src/index.ts')]: UI_INDEX_HEAD, '.changeset/x.md': UI_CS }, + ...over, + }); + + const wsG1 = run(mk(UI_FIXTURE())); + green('WS-G1 a `ui/packages/*` package retires a `.tsx` export, an alias of one and a type behind a `.ts` shim', wsG1); + const detail = wsG1.judged[0]?.detail ?? ''; + assert( + /useExpressionContext \(export, ui\/packages\/app-shell\/src\/providers\/ExpressionProvider\.tsx\)/.test(detail) && + /MetadataPageShell \(export, ui\/packages\/app-shell\/src\/views\/PageShell\.tsx\)/.test(detail) && + /RecentItemInput \(export, ui\/packages\/app-shell\/src\/context\/RecentItemsProvider\.tsx\)/.test(detail), + 'WS-G1b: predicate 2 must locate each declaration where the compiler resolves it -- the `.tsx` module, ' + + `the ALIASED declaration, and the module BEHIND the shim. Got: ${detail}`, + ); + + // The membership comes from the workspace file and nowhere else: the same tree + // with the `ui/packages/*` glob dropped has no manifest named the package. + red('WS-R1 a package whose directory no workspace glob names has no workspace manifest', run(mk(UI_FIXTURE({ + workspace: WORKSPACE_YAML.replace(' - ui/packages/*\n', ''), + }))), [/no workspace manifest at [0-9a-f]{12} is named `@object-ui\/app-shell`/]); + + let thrown = null; + try { run(mk(UI_FIXTURE({ workspace: null }))); } catch (e) { thrown = e; } + assert(thrown instanceof WorkspaceEnumerationError && /pnpm-workspace\.yaml at [0-9a-f]{12} is missing/.test(thrown.message), + `WS-R2: a rev with no workspace file is REFUSED, never read as an empty workspace (got ${thrown?.message ?? 'no throw'})`); + + green('WS-G2 `unpublished` on a private `ui/apps/*` package', run(mk({ + pkgs: UI_PKGS, + files: { + '.changeset/x.md': CS({ + bumps: [['@object-ui/console', 'major']], + body: '**BREAKING** x\n\n\n', + }), + }, + }))); + + const GLOBS = ['packages/*', 'ui', 'ui/packages/*']; + assert(isWorkspaceManifest('ui/packages/app-shell/package.json', GLOBS), 'WS-U1: `/*` names a directory directly under ``'); + assert(isWorkspaceManifest('ui/package.json', GLOBS), 'WS-U2: a bare `` glob names that directory itself'); + assert(!isWorkspaceManifest('ui/packages/app-shell/test/fixture/package.json', GLOBS), + 'WS-U3: a manifest nested below a member is not a member -- a fixture package.json cannot shadow a real one'); + assert(!isWorkspaceManifest('ui/node_modules/x/package.json', ['ui/*']) && !isWorkspaceManifest('ui/.cache/package.json', ['ui/*']), + 'WS-U4: `node_modules` and dot-directories are never members (the enumerator\'s rule)'); + let rich = null; + const richDir = mk({ files: {}, workspace: 'packages:\n - packages/**\n' }).dir; + try { workspaceGlobsAt('HEAD', richDir); } catch (e) { rich = e; } + assert(rich instanceof WorkspaceEnumerationError && /richer than/.test(rich.message), + `WS-U5: a glob richer than \`\` / \`/*\` is refused, never matched against nothing (got ${rich?.message ?? 'no throw'})`); + } + // ---- G6: a changeset that was ALREADY breaking at base is inherited ------- battery('G6: a changeset that was ALREADY breaking at base is inherited'); { diff --git a/scripts/pm/bare-root-worklist.mjs b/scripts/pm/bare-root-worklist.mjs index 56e62339916..624f29660f5 100644 --- a/scripts/pm/bare-root-worklist.mjs +++ b/scripts/pm/bare-root-worklist.mjs @@ -1201,31 +1201,6 @@ const TRIAGE = new Map([ + 'above: that gate walks the same root RECURSIVELY at 49 of 50, where the precise ' + 'spelling buys one file of discrimination and its deferral is untouched by this row', }], - ['scripts/check-adr-0087-registration.mjs PACKAGE_ROOTS packages', { - verdict: 'SPELLABLE-UNDECLARED', - spelling: 'packages manifests', - why: 'workspace manifests only — 74 of 5275 (1.4%), re-measured 2026-08-26. The recorded ' - + 'spelling is live and reaches 74 of 74, 100% precise and complete. Deferred: six rows ' - + 'across two source files share this one population shape and no consumer has asked for ' - + 'any of them, so declaring here is a six-edit expansion ahead of demand — RE-COUNTED ' - + '2026-09-04. The "nine rows across three gates" this replaces counted CI invocation ' - + 'names, not source files: check:changeset-gate-self-tests runs THIS script --self-test, ' - + 'so it never was a third gate, and #15127 keying this table on the gate source file is ' - + 'what made that visible: the six are three roots each under this gate and its ' - + 'check-skill-compatibility-version.mjs sibling', - }], - ['scripts/check-adr-0087-registration.mjs PACKAGE_ROOTS apps', { - verdict: 'SPELLABLE-UNDECLARED', - spelling: 'apps manifests', - why: 'workspace manifests only — 1 of 40 (2.5%), re-measured 2026-08-26; the spelling reaches ' - + '1 of 1, 100% precise and complete. Deferred with its packages half', - }], - ['scripts/check-adr-0087-registration.mjs PACKAGE_ROOTS examples', { - verdict: 'SPELLABLE-UNDECLARED', - spelling: 'examples manifests', - why: 'workspace manifests only — 4 of 241 (1.7%), re-measured 2026-08-26; the spelling reaches ' - + '4 of 4, 100% precise and complete. Deferred with its packages half', - }], ['scripts/check-skill-compatibility-version.mjs PACKAGE_ROOTS packages', { verdict: 'SPELLABLE-UNDECLARED', spelling: 'packages manifests', @@ -2093,7 +2068,10 @@ const SELF_TEST_BATTERIES = Object.freeze({ // 128 → 126 (#24037): the loop registers two cases per recorded row, and the // four check-position-name-fold-loaders rows were withdrawn (134 → 126 on a // run) — fewer rows to judge, not cases that stopped registering. - "The DECLARATION a row describes, read from the gate's own SOURCE": 126, + // 126 → 122 (#26220): the three check-adr-0087-registration PACKAGE_ROOTS rows + // were deleted — that gate now reads its package globs from the workspace file, + // so the bare words they judged no longer exist (128 → 122 on a run). + "The DECLARATION a row describes, read from the gate's own SOURCE": 122, 'CENSUS_REFUSE_WIDE, pinned on its OWN terms (#14695)': 5, });