Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 183 additions & 6 deletions scripts/check-adr-0087-registration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────
//
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
* `<dir>` / `<dir>/*` 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 \`<dir>\` or \`<dir>/*\`, 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 `<dir>/package.json`) a workspace member's
* manifest under `globs`? `<dir>` names that directory; `<dir>/*` 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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -5587,7 +5652,16 @@ function selfTest() {
'# ADR-0087 (fixture)\n\n' +
cats.map((c) => `<!-- adr-0087: not-required (${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 });
Expand All @@ -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.)
Expand Down Expand Up @@ -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 = () => <div className="shell" />;\n',
[uiPath('src/providers/ExpressionProvider.tsx')]:
'export function useExpressionContext() {\n return <span>{1}</span>;\n}\n',
[uiPath('src/views/PageShell.tsx')]: 'export function PageShell() {\n return <main />;\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 <ul />;\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<!-- adr-0087: not-required (runtime-export-retired${UI_OPERANDS.map((o) => ` ${UI_PKG}#${o}`).join(',')}) ` +
'the retired names are runtime exports; the compiler reports each at its importer, and no stored shape moves -->\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<!-- adr-0087: not-required (unpublished) the console app is private and publishes to no registry -->\n',
}),
},
})));

const GLOBS = ['packages/*', 'ui', 'ui/packages/*'];
assert(isWorkspaceManifest('ui/packages/app-shell/package.json', GLOBS), 'WS-U1: `<dir>/*` names a directory directly under `<dir>`');
assert(isWorkspaceManifest('ui/package.json', GLOBS), 'WS-U2: a bare `<dir>` 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 \`<dir>\` / \`<dir>/*\` 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');
{
Expand Down
30 changes: 4 additions & 26 deletions scripts/pm/bare-root-worklist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
});

Expand Down
Loading