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,
});