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