From 0263a192c00f2889de1adf13474710b6118f267d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:06:04 +0000 Subject: [PATCH] tooling(ui-wiring): keep four objectstack gates inert-correct once ui/ is imported With objectui's tree under ui/, four objectstack-side tools stay red for reasons of their own, measured on the verification branch. Each change gives byte-identical results on a tree with no ui/ directory. - partition-test-shards: members whose path is ui or under it are dropped from the Test Core document (Test UI runs them), by path rather than by scope so the ui root package is caught too. - root build: excludes the docs site under ui/apps/site with a path glob turbo accepts while ui/ does not exist. - check-type-check-coverage: tsconfig files are read with TypeScript's own JSONC parser (block comments, trailing commas), refusals kept. - check-published-readme-exports: the ui/ tree's members and documents are out of this gate's population, by path. Claude-Session: https://claude.ai/code/session_01Y9KdeUcccMQ3ReF85Xyf5o Co-authored-by: Claude --- package.json | 2 +- scripts/check-published-readme-exports.mjs | 126 ++++++++++++++++++++- scripts/check-type-check-coverage.mjs | 108 ++++++++++++++++-- scripts/partition-test-shards.mjs | 108 +++++++++++++++++- 4 files changed, 324 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index e28703fbf82..3b8b2f6bbab 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations", "scripts": { - "build": "turbo run build --filter=!@objectstack/docs", + "build": "turbo run build --filter=!@objectstack/docs --filter=!./ui*/apps/site", "dev": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", "dev:showcase": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", "dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev", diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index 1fa7a4b660b..593458c5b24 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -2217,8 +2217,44 @@ function prerequisiteNotMetText(message) { * result depends on whether it was passed. `root` is the self-test's * scratch workspace and defaults to the repo — see `publishedDocs`. */ +/** + * The directory objectui's tree is imported under (objectstack#25677), as a + * bare segment: a literal with no path separator is no watch hint, so this + * declares no population (see DECLARED_WORKSPACE_POPULATION). + */ +const UI_TREE = 'ui'; + +/** Is a repo-relative POSIX path the `ui/` tree or inside it? */ +export function inUiTree(rel) { + return rel === UI_TREE || rel.startsWith(`${UI_TREE}/`); +} + +/** + * This gate's population with the `ui/` tree taken out, by PATH: every member + * whose directory is `ui` or under it, and every document it publishes. + * + * Those packages' README claims are objectui's own gate's to judge + * (`ui/scripts/check-readme-exports.mjs`, which objectui runs in its + * `repo-gates.yml` after a full build), and their built type entries are not + * in the closure this gate's lane builds -- so left + * in, they turn every run on the merged tree into PREREQUISITE NOT MET over + * packages it was never asked about, and the objectstack READMEs go unjudged + * with them. By path and not by `@object-ui/` scope: the `ui/` tree is the unit + * the import defined, and a scope test would miss a member of it with another + * name. A tree with no `ui/` directory comes back unchanged. + * + * An objectstack README that imports a `ui/` member now reads it as a + * non-member, the same as `react`: not this repo's own scope, so skipped. + */ +export function outsideUiTree({ byName, docs }) { + return { + byName: new Map([...byName].filter(([, member]) => !inUiTree(member.dir))), + docs: docs.filter((doc) => !inUiTree(doc.file)), + }; +} + function run({ unreadReport: wantsUnreadReport = false, root = ROOT } = {}) { - const { byName, docs } = publishedDocs(SELF, { root }); + const { byName, docs } = outsideUiTree(publishedDocs(SELF, { root })); // Pass 1: which workspace type entries do the READMEs actually reach? const { importStatements, ownScope, unresolvable, targets: reached } = reachedTargets( @@ -2497,11 +2533,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'THE FOURTH STATE ON THAT AXIS (#10417)': 4, 'THE UNBUILT-CLOSURE PREDICATE, and the two states it excludes (#15600)': 5, 'THE EXIT-CODE SPLIT, END TO END on a scratch workspace (#15600)': 8, + 'THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH (#25697)': 5, }); // 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 = 26; +const SELF_TEST_BATTERY_FLOOR = 27; // 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 @@ -4298,6 +4335,91 @@ function selfTest() { rmSync(fixtureRoot, { recursive: true, force: true }); } + // -- THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH --------------------- + // + // Driven through the production `run` over a scratch workspace, like the + // split above. The `ui/` member is UNBUILT on purpose: that is the merged + // tree's state in this gate's lane, and left in the population it refuses the + // whole run. The control puts the same unbuilt member outside `ui/` and must + // still refuse, so the green run is the exclusion and not a fixture that + // never reached the refusal. + battery('THE `ui/` TREE IS OUT OF THIS POPULATION, BY PATH (#25697)'); + eq( + 'inUiTree — the tree and its descendants, by path segment', + ['ui', 'ui/packages/types', 'ui/README.md', 'uikit/x', 'packages/ui', 'packages/ui/README.md'].map(inUiTree), + [true, true, true, false, false, false], + ); + eq( + 'outsideUiTree — drops the members under `ui/` and the documents they publish, keeps the rest', + (() => { + const kept = outsideUiTree({ + byName: new Map([ + ['@objectstack/a', { dir: 'packages/a' }], + ['@object-ui/b', { dir: 'ui/packages/b' }], + ['object-ui-root', { dir: 'ui' }], + ['@objectstack/ui-kit', { dir: 'packages/ui' }], + ]), + docs: [{ file: 'packages/a/README.md' }, { file: 'ui/packages/b/README.md' }, { file: 'ui/README.md' }], + }); + return [[...kept.byName.keys()], kept.docs.map((d) => d.file)]; + })(), + [['@objectstack/a', '@objectstack/ui-kit'], ['packages/a/README.md']], + ); + const uiRoot = mkdtempSync(join(tmpdir(), 'readme-exports-ui-tree-')); + try { + const member = (rel, name, built) => { + const dir = join(uiRoot, rel); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + `${JSON.stringify( + { + name, + version: '0.0.0', + files: ['dist', 'README.md'], + exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' } }, + }, + null, + 2, + )}\n`, + ); + writeFileSync(join(dir, 'README.md'), ['```typescript', `import { Documented } from '${name}';`, '```', ''].join('\n')); + if (built) writeFileSync(join(dir, 'dist', 'index.d.ts'), 'export declare const Documented: string;\n'); + }; + mkdirSync(join(uiRoot, 'scripts'), { recursive: true }); + writeFileSync(join(uiRoot, BASELINE_REL), `${JSON.stringify({ entries: [] }, null, 2)}\n`); + member('packages/fixture', '@objectstack/ui-tree-fixture', true); + const drive = (globs) => { + writeFileSync(join(uiRoot, WORKSPACE_FILE), `packages:\n${globs.map((g) => ` - ${g}\n`).join('')}`); + const printed = []; + const { log, error } = console; + console.log = (...args) => printed.push(args.join(' ')); + console.error = (...args) => printed.push(args.join(' ')); + try { + return { exit: run({ root: uiRoot }), text: printed.join('\n') }; + } finally { + console.log = log; + console.error = error; + } + }; + + member('ui/packages/unbuilt', '@object-ui/ui-tree-unbuilt', false); + const merged = drive(['packages/*', 'ui/packages/*']); + eq('END TO END — an unbuilt member under `ui/` does not refuse the run', merged.exit, EXIT_OK); + eq('END TO END — and the run does not name it', /ui-tree-unbuilt/.test(merged.text), false); + + member('packages/unbuilt', '@object-ui/ui-tree-unbuilt', false); + rmSync(join(uiRoot, 'ui'), { recursive: true, force: true }); + const control = drive(['packages/*']); + eq( + 'END TO END — CONTROL: the same unbuilt member outside `ui/` still refuses, naming it', + [control.exit, /@object-ui\/ui-tree-unbuilt -> packages\/unbuilt\/dist\/index\.d\.ts/.test(control.text)], + [EXIT_PREREQUISITE_NOT_MET, true], + ); + } finally { + rmSync(uiRoot, { recursive: true, force: true }); + } + // The authority convention (#8435): the baseline path must never be offered // as a co-equal author remedy. const remedy = freshRemedy(); diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index c1d3a3e17de..51ad0c88452 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -528,7 +528,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, import { tmpdir, totalmem } from 'node:os'; import { join, posix, resolve } from 'node:path'; import { getHeapStatistics } from 'node:v8'; +import ts from 'typescript'; import { gitFreeEnv } from './git-env.mjs'; +import { maskComments } from './js-comment-mask.mjs'; import { selfTest as workspaceEnumeratorSelfTest, workspaceEnumeratorFloorFailures, @@ -1623,14 +1625,38 @@ function includeRoot(glob) { return glob.split('*')[0].replace(/\/$/, ''); } +/** + * A tsconfig's text read the way tsc reads it: JSON with comments, line AND + * block, and trailing commas. `JSON.parse` over the text with whole-line `//` + * comments stripped was the reader here, and it refused every config tsc + * accepts with a block comment or a trailing comma -- objectui's root + * `tsconfig.json` and three of its package configs are written that way, so + * the gate stopped on the first of them the moment `ui/` became a workspace + * member (objectstack#25697). + * + * TypeScript's own parser is the authority, not a second JSONC dialect typed + * here: what tsc compiles is what this gate claims to describe. Its refusals + * stay refusals -- a syntax error, a merge-conflict marker, a root that is not + * an object -- and so does a document holding no value at all, which the + * parser reads as `{}` and the old reader refused: an empty config read as + * "include everything, exclude nothing" is the silent pass this gate exists to + * refuse. + * + * @returns {{ config: object } | { error: string }} + */ +function parseTsconfigText(text, fileName) { + if (maskComments(text).trim() === '') return { error: 'the file holds no JSON value' }; + const { config, error } = ts.parseConfigFileTextToJson(fileName, text); + if (error) return { error: ts.flattenDiagnosticMessageText(error.messageText, '\n') }; + return { config }; +} + function readTsconfig(dir, file) { - const raw = readFileSync(join(ROOT, dir, file), 'utf8').replace(/^\s*\/\/.*$/gm, ''); - let parsed; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged`, { cause }); + const read = parseTsconfigText(readFileSync(join(ROOT, dir, file), 'utf8'), `${dir}/${file}`); + if (read.error !== undefined) { + throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged: ${read.error}`); } + const parsed = read.config; const include = parsed.include ?? null; const declared = Array.isArray(include) ? include.filter((g) => typeof g === 'string') : []; const roots = declared.length > 0 @@ -4169,12 +4195,15 @@ function tsconfigChainFacts(dir) { let configAbs = join(ROOT, dir, 'tsconfig.json'); const facts = { selectsFiles: false, declaresTypeRoots: false }; for (let depth = 0; depth < 8; depth++) { - let parsed; + let text; try { - parsed = JSON.parse(readFileSync(configAbs, 'utf8').replace(/^\s*\/\/.*$/gm, '')); + text = readFileSync(configAbs, 'utf8'); } catch { return facts; } + const read = parseTsconfigText(text, configAbs); + if (read.error !== undefined) return facts; + const parsed = read.config; if (Array.isArray(parsed.include) && parsed.include.length > 0) facts.selectsFiles = true; if (Array.isArray(parsed.files)) facts.selectsFiles = true; if (parsed.compilerOptions?.typeRoots !== undefined) facts.declaresTypeRoots = true; @@ -4348,10 +4377,12 @@ function remeasureProject({ pkgAbs, rootAbs, parsed, unreachable, chain }) { function measureTestDebt(dir, hiddenTests = []) { const rootAbs = ROOT.replaceAll('\\', '/').replace(/\/$/, ''); const pkgAbs = join(ROOT, dir).replaceAll('\\', '/').replace(/\/$/, ''); - const parsed = JSON.parse( - readFileSync(join(ROOT, dir, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, ''), - ); + // readTsconfig first: it is the reader that refuses an unparseable config. const { roots } = readTsconfig(dir, 'tsconfig.json'); + const { config: parsed } = parseTsconfigText( + readFileSync(join(ROOT, dir, 'tsconfig.json'), 'utf8'), + `${dir}/tsconfig.json`, + ); const unreachable = hiddenTests.filter( (rel) => !roots.some((root) => root === '' || rel === root || rel.startsWith(`${root}/`)), ); @@ -5051,6 +5082,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ // The closure build's shared-verify-lock contract: 11 verdict-reader rows, 2 // wiring pins, 6 real-lock cases on a private lock file. 'verify-lock cases (#23470)': 19, + // parseTsconfigText: what tsc accepts reads, what tsc refuses still refuses. + 'JSONC tsconfig reader (#25697)': 8, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -5059,7 +5092,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // key in the literal above, so the roster falls below this number; the table // cross-check in `batteryFloorFailures()` is the other half, and names WHICH // label collided. -const SELF_TEST_BATTERY_FLOOR = 65; +const SELF_TEST_BATTERY_FLOOR = 66; // The ledger `batteryFloorFailures()` reads. // @@ -7695,6 +7728,57 @@ function selfTest() { if (!c.ok) failures.push(`prerequisiteNotMetText — ${c.label}`); } + // ── THE JSONC TSCONFIG READER (#25697) ───────────────────────────────────── + // + // tsc reads tsconfig files as JSON with comments and trailing commas, and so + // must this gate: the accepted rows below are all shapes tsc compiles, and + // the first is objectui's root config's own (a block comment between keys). + // The refused rows are what keeps "tolerant" from meaning "anything parses": + // each one used to throw under JSON.parse and must still come back as an + // error, never as a config that excludes nothing. + const jsoncCases = [ + { + label: 'a block comment between keys reads', + text: '{\n "compilerOptions": {\n "skipLibCheck": true,\n\n /* Bundler mode */\n' + + ' "moduleResolution": "bundler"\n },\n "include": ["src"]\n}\n', + include: ['src'], + }, + { + label: 'trailing commas in an object and an array read', + text: '{ "include": ["src", "test",], "exclude": ["**/*.test.ts",], }', + include: ['src', 'test'], + exclude: ['**/*.test.ts'], + }, + { + label: 'a whole-line `//` comment still reads, as it did before', + text: '// the base config\n{ "include": ["src/**/*"] }', + include: ['src/**/*'], + }, + { + label: 'a `//` inside a string value is the value, not a comment', + text: '{ "include": ["src"], "compilerOptions": { "baseUrl": "http://x//y" } }', + include: ['src'], + }, + { label: 'a truncated document is refused', text: '{ "include": [', refused: true }, + { label: 'a merge-conflict marker is refused', text: '<<<<<<< HEAD\n{ "include": ["src"] }', refused: true }, + { label: 'a root that is not an object is refused', text: '["src"]', refused: true }, + { label: 'a document holding only a comment is refused, not read as {}', text: '// nothing here\n', refused: true }, + ]; + for (const c of jsoncCases) { + registerCase('JSONC tsconfig reader (#25697)'); + const got = parseTsconfigText(c.text, 'fixture/tsconfig.json'); + if (c.refused) { + if (got.error === undefined) { + failures.push(`parseTsconfigText — ${c.label}: read as ${JSON.stringify(got.config)} instead of refused`); + } + continue; + } + const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + if (got.error !== undefined || !same(got.config.include, c.include) || !same(got.config.exclude, c.exclude)) { + failures.push(`parseTsconfigText — ${c.label}: got ${JSON.stringify(got)}`); + } + } + // The shared workspace enumerator is a plain module with no CI invocation of // its own (#11510); every gate that consolidated onto it folds in its checks. // ⛔ NOT a battery of this file either, for the same reason as the fold-in diff --git a/scripts/partition-test-shards.mjs b/scripts/partition-test-shards.mjs index cf45bf55c72..64612ed6f09 100644 --- a/scripts/partition-test-shards.mjs +++ b/scripts/partition-test-shards.mjs @@ -1359,6 +1359,38 @@ export function sliceMapForRun(names, shardCount) { return Object.freeze(Object.fromEntries(names.map((name) => [name, shardCount]))); } +// ── THE `ui/` TREE IS TEST UI'S, NOT TEST CORE'S (#25697) ────────────────── +// +// Once objectui's tree sits under `ui/` (objectstack#25677) its packages are +// workspace members, so `turbo ls` lists them and every Test Core shard would +// receive some. They have their own matrix in ci.yml (Test UI), and Test Core +// cannot run them: its closure builds under OS_SKIP_DTS, and the ui packages +// compile against built declarations (TS7016), while the ui ROOT package's +// `test` script is objectui's whole-suite vitest -- measured on the verification +// branch, shard 1 ran it for 38 minutes. So members whose directory is `ui` or +// under it are dropped from the document here, BY PATH: the `ui/` tree is the +// unit the import defined, and a scope test (`@object-ui/*`) would miss the +// root package, which carries another name. A document with no `ui/` member +// comes back unchanged, so today's split is byte-identical. +// +// The segment is spelled bare: a literal with no path separator is no +// dispatch-gates watch hint, so this declares no `ui/**` population. +const UI_TREE = 'ui'; + +export function inUiTree(itemPath) { + return itemPath === UI_TREE || itemPath.startsWith(`${UI_TREE}/`); +} + +// `turbo ls` writes repo-relative paths and an older union writer absolute ones +// (packageDir() above), so the test runs on the path relative to the repo. An +// entry with no string path is kept: weighItems() refuses it, naming it. +export function withoutUiTree(items) { + const underUi = (it) => + typeof it?.path === 'string' && inUiTree(path.relative(REPO_ROOT, packageDir(it.path)).split(path.sep).join('/')); + const kept = items.filter((it) => !underUi(it)); + return { kept, dropped: items.length - kept.length }; +} + // The split a real run computes, and the ONE path that computes it: main() runs // it and prints the result, and the self-test drives the same function on the // nightly's live document, so a pin on it cannot be a pin on a second copy of @@ -1366,8 +1398,9 @@ export function sliceMapForRun(names, shardCount) { // shape, then the dataset, the weights, the density cap and the plan -- so a run // that refuses still refuses with the same message it always did. export function planRun(parsed, listPath, shardCount, { excluded = new Set(), timings = null } = {}) { - const items = readPackageItems(parsed, listPath); - const acrossShards = readSliceAcrossShards(parsed, items, listPath); + const listed = readPackageItems(parsed, listPath); + const acrossShards = readSliceAcrossShards(parsed, listed, listPath); + const { kept: items, dropped: uiTree } = withoutUiTree(listed); const dataset = timings ?? loadTimings(); const sliced = acrossShards === null ? FILE_SHARDED_PACKAGES : sliceMapForRun(acrossShards, shardCount); const { weighed, estimated, packages } = weighItems(items, excluded, dataset, listPath, sliced); @@ -1377,7 +1410,7 @@ export function planRun(parsed, listPath, shardCount, { excluded = new Set(), ti const cap = densityCapOf(dataset, excluded, shardCount); const { items: weighted, bins: placed, decisions } = planShards(weighed, shardCount, { cap }); const bins = assertSlicesSpread(placed); - return { bins, weighted, decisions, estimated, packages, cap, acrossShards }; + return { bins, weighted, decisions, estimated, packages, cap, acrossShards, uiTree }; } // -- The self-test's own battery roster and floor (#13489) ------------------ @@ -1428,11 +1461,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ // 16 read the live tree: 2 read test-nightly-tiers.yml back, and 3 drive the // nightly's own document at that workflow's shard count. 'a document states its own slicing: the nightly tiers': 16, + // Members under `ui/` are Test UI's: dropped by path, the rest unperturbed. + 'the `ui/` tree is Test UI\'s, not Test Core\'s (#25697)': 6, }); // 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 = 14; +const SELF_TEST_BATTERY_FLOOR = 15; // 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 @@ -2694,6 +2729,65 @@ function selfTest() { } }); + // -- THE `ui/` TREE IS TEST UI'S, NOT TEST CORE'S -------------------------- + // + // The merged tree's `turbo ls` lists the ui ROOT package (path `ui`, another + // name than `@object-ui/*`) and the members under it. Through planRun() -- + // the function main() runs -- none of them may reach a bin, and the + // objectstack items must split exactly as they do with no `ui/` tree at all. + battery('the `ui/` tree is Test UI\'s, not Test Core\'s (#25697)'); + { + check(() => { + const got = ['ui', 'ui/packages/types', 'ui/apps/console', 'uikit', 'packages/ui', 'packages/spec'].map(inUiTree); + if (JSON.stringify(got) !== JSON.stringify([true, true, true, false, false, false])) { + throw new Error(`ui tree: inUiTree answered ${JSON.stringify(got)}`); + } + }); + const coreItems = [ + { name: '@objectstack/spec', path: 'packages/spec' }, + { name: '@objectstack/cli', path: 'packages/cli' }, + { name: '@objectstack/core', path: 'packages/core' }, + { name: '@objectstack/ui-named', path: 'packages/ui' }, + ]; + const uiItems = [ + { name: 'object-ui-monorepo', path: 'ui' }, + { name: '@object-ui/types', path: 'ui/packages/types' }, + { name: '@object-ui/console', path: path.join(REPO_ROOT, 'ui', 'apps', 'console') }, + ]; + const doc = (list) => ({ packages: { count: list.length, items: list } }); + const corePlan = planRun(doc(coreItems), 'core only', SHARD_COUNT, { timings }); + const mergedPlan = planRun(doc([...uiItems, ...coreItems]), 'merged tree', SHARD_COUNT, { timings }); + const placedNames = (plan) => plan.bins.flatMap((bin) => bin.names); + check(() => { + const uiNames = new Set(uiItems.map((u) => u.name)); + const leaked = placedNames(mergedPlan).filter((n) => uiNames.has(parseShardItem(n).name)); + if (leaked.length > 0) throw new Error(`ui tree: ${leaked.join(', ')} reached a Test Core bin`); + }); + check(() => { + if (mergedPlan.uiTree !== uiItems.length) { + throw new Error( + `ui tree: planRun reported ${mergedPlan.uiTree} dropped, expected ${uiItems.length} (an absolute path too)` + ); + } + }); + check(() => { + if (JSON.stringify(mergedPlan.bins) !== JSON.stringify(corePlan.bins)) { + throw new Error('ui tree: dropping the ui members changed how the objectstack items split'); + } + }); + check(() => { + if (corePlan.uiTree !== 0 || !placedNames(corePlan).includes('@objectstack/ui-named')) { + throw new Error('ui tree: a package outside `ui/` whose path merely contains "ui" was dropped'); + } + }); + check(() => { + const kept = withoutUiTree([{ name: 'no-path' }, ...uiItems]); + if (kept.dropped !== uiItems.length || kept.kept.length !== 1) { + throw new Error('ui tree: an entry with no path must be kept for weighItems() to refuse, not dropped'); + } + }); + } + // -- A DOCUMENT STATES ITS OWN SLICING: THE NIGHTLY TIERS ----------------- // // The nightly tiers' shard count is their own (test-nightly-tiers.yml), and @@ -3002,7 +3096,7 @@ function main() { if (shardIndex > shardCount) throw new Error(`--shard ${shardSpec}: index exceeds count`); const parsed = JSON.parse(readFileSync(listPath, 'utf8')); - const { bins, weighted, decisions, estimated, packages, cap, acrossShards } = planRun( + const { bins, weighted, decisions, estimated, packages, cap, acrossShards, uiTree } = planRun( parsed, listPath, shardCount, @@ -3035,6 +3129,10 @@ function main() { "FILE_SHARDED_PACKAGES (Test Core's map) does not apply to this run" ); } + // Only a document listing `ui/` members prints this, so today's log is unchanged. + if (uiTree > 0) { + console.error(` ui/ tree: ${uiTree} member(s) not partitioned here -- Test UI runs them`); + } for (const d of decisions) console.error(` slicing: ${renderSliceDecision(d)}`); for (const name of mine.names) console.log(name); }