diff --git a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts index 5750da917..b53d4616e 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts @@ -18,6 +18,7 @@ import fs from 'fs/promises'; import path from 'path'; import { createRequire } from 'node:module'; +import type Parser from 'tree-sitter'; import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js'; import { logger } from '../../logger.js'; @@ -221,10 +222,24 @@ function admits(scope: WorkspaceScope | null, dir: string): boolean { // The root package is always itself, workspace or not. if (dir === '') return true; if (scope === null) return false; - if (scope.exclude.some((pattern) => globToRegExp(pattern).test(dir))) return false; + // An exclusion covers the directory AND everything under it: `!packages/legacy` + // must keep `packages/legacy/foo` out even when a nested workspace root inside + // the excluded subtree re-declares `packages/*`. + if (scope.exclude.some((pattern) => matchesDirOrAncestor(globToRegExp(pattern), dir))) + return false; return scope.include.some((pattern) => globToRegExp(pattern).test(dir)); } +function matchesDirOrAncestor(re: RegExp, dir: string): boolean { + let current = dir; + while (current !== '') { + if (re.test(current)) return true; + const slash = current.lastIndexOf('/'); + current = slash === -1 ? '' : current.slice(0, slash); + } + return false; +} + /** * Match one workspace glob. * @@ -272,9 +287,50 @@ function globToRegExp(pattern: string): RegExp { * tooling that does not read pnpm's file). */ async function loadWorkspaceScope(repoRoot: string): Promise { - const patterns: string[] = []; + // Every workspace ROOT in the repo, not just the top level. keycloak keeps its + // JavaScript workspace at `js/pnpm-workspace.yaml`; reading only the repo root + // found no workspace, admitted no package, and `@keycloak/keycloak-ui-shared` + // (780–845 raw calls per SHA) resolved 7 times. Patterns from a nested root + // are rebased onto that root so `packages/*` under `js/` admits `js/packages/x`. + // + // Gated, though: a nested root counts only when the repo root declares NO + // workspace (keycloak), or when the nested root's directory is itself admitted + // by the root's scope. Unioning every nested root unconditionally let an + // `examples/*/package.json` starter (turborepo/vite/nuxt templates carry + // `workspaces`) admit its example packages — and, being shallow, outrank the + // real package of the same name — and let a root inside an excluded subtree + // re-admit what the outer `!exclusion` had removed. + const rootPatterns = await readWorkspacePatternsAt(repoRoot); + const rootScope = rootPatterns.length === 0 ? null : toScope(rootPatterns); + const patterns: string[] = [...rootPatterns]; + for (const root of await findWorkspaceRoots(repoRoot)) { + if (root === repoRoot) continue; + const prefix = repoRelativeDir(repoRoot, root); + if (rootScope !== null && !admits(rootScope, prefix)) continue; + const rebase = (p: string): string => { + const negated = p.startsWith('!'); + const body = negated ? p.slice(1) : p; + const joined = prefix === '' ? body : `${prefix}/${body.replace(/^\.\//, '')}`; + return negated ? `!${joined}` : joined; + }; + for (const pattern of await readWorkspacePatternsAt(root)) patterns.push(rebase(pattern)); + } - const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json')); + if (patterns.length === 0) return null; + return toScope(patterns); +} + +function toScope(patterns: readonly string[]): WorkspaceScope { + return { + include: patterns.filter((p) => !p.startsWith('!')), + exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)), + }; +} + +/** The workspace patterns declared at ONE directory, all three spellings merged. */ +async function readWorkspacePatternsAt(root: string): Promise { + const patterns: string[] = []; + const rootManifest = await readJsonFile(path.join(root, 'package.json')); const workspaces = rootManifest?.workspaces; if (Array.isArray(workspaces)) { patterns.push(...workspaces.filter((w): w is string => typeof w === 'string')); @@ -285,20 +341,81 @@ async function loadWorkspaceScope(repoRoot: string): Promise typeof w === 'string')); } } - - patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yaml')))); - patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yml')))); - - const lerna = await readJsonFile(path.join(repoRoot, 'lerna.json')); + patterns.push(...(await readYamlPackages(path.join(root, 'pnpm-workspace.yaml')))); + patterns.push(...(await readYamlPackages(path.join(root, 'pnpm-workspace.yml')))); + const lerna = await readJsonFile(path.join(root, 'lerna.json')); if (Array.isArray(lerna?.packages)) { patterns.push(...lerna.packages.filter((w): w is string => typeof w === 'string')); } + return patterns; +} - if (patterns.length === 0) return null; - return { - include: patterns.filter((p) => !p.startsWith('!')), - exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)), - }; +/** + * Directories that declare a workspace: the repo root plus any directory (to a + * shallow depth — workspace roots sit near the top) holding a + * `pnpm-workspace.yaml`, a `lerna.json`, or a `package.json` with `workspaces`. + */ +const WORKSPACE_ROOT_MAX_DEPTH = 4; +/** + * Bound on the depth-≤4 directory walk. Generous on purpose: the previous 2,000 + * cap tripped silently in readdir order, so WHICH packages existed — and which + * imports resolved — varied between two checkouts of the same commit. Tripping + * it now warns, so a truncated scan is a logged fact rather than a quiet one. + */ +const WORKSPACE_ROOT_SCAN_MAX_DIRS = 50_000; +/** Directory names whose nested `workspaces` are starters/fixtures, never members. */ +const NON_MEMBER_ROOT_DIRS = new Set([ + 'example', + 'examples', + 'fixture', + 'fixtures', + 'template', + 'templates', + 'sample', + 'samples', +]); +async function findWorkspaceRoots(repoRoot: string): Promise { + const roots: string[] = [repoRoot]; + const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + let scanned = 0; + while (scanned < queue.length) { + if (scanned >= WORKSPACE_ROOT_SCAN_MAX_DIRS) { + logger.warn( + `[node] workspace-root scan of ${repoRoot} hit the ${WORKSPACE_ROOT_SCAN_MAX_DIRS}-directory cap; nested workspace roots below it were not considered`, + ); + break; + } + const { dir, depth } = queue[scanned++]!; + let entries: import('fs').Dirent[]; + try { + entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + } catch { + continue; + } + if (dir !== repoRoot) { + const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name)); + let declares = + names.has('pnpm-workspace.yaml') || + names.has('pnpm-workspace.yml') || + names.has('lerna.json'); + if (!declares && names.has('package.json')) { + const manifest = await readJsonFile(path.join(dir, 'package.json')); + declares = manifest?.workspaces !== undefined && manifest.workspaces !== null; + } + if (declares) roots.push(dir); + } + if (depth >= WORKSPACE_ROOT_MAX_DEPTH) continue; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (NON_MEMBER_ROOT_DIRS.has(entry.name.toLowerCase())) continue; + const child = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, child)) continue; + queue.push({ dir: child, depth: depth + 1 }); + } + } + return roots; } async function readJsonFile(filePath: string): Promise | null> { @@ -334,27 +451,64 @@ async function readYamlPackages(filePath: string): Promise { * declaration, so this is far cheaper than the C# namespace scan next door, * which reads every `.cs` file. */ +/** + * Per-repo memo. The TS/JS/Vue scope resolvers and the unresolved-call ledger + * classifier each ask for the same map during one analyze; without this the + * 20k-directory walk ran once per asker (four times on a full run, one of them + * inside the index lock). Invalidated at the start of every `runFullAnalysis` + * so a long-lived server never serves a stale package map across runs. + */ +const workspacePackagesMemo = new Map>(); + +export function invalidateNodeWorkspacePackages(repoRoot?: string): void { + if (repoRoot === undefined) workspacePackagesMemo.clear(); + else workspacePackagesMemo.delete(path.resolve(repoRoot)); +} + export async function loadNodeWorkspacePackages( repoRoot: string, +): Promise { + const key = path.resolve(repoRoot); + const cached = workspacePackagesMemo.get(key); + if (cached !== undefined) return cached; + const pending: Promise = loadNodeWorkspacePackagesUncached( + repoRoot, + ).catch((err: unknown) => { + // Evict only OUR entry. If this load was invalidated while in flight and a + // newer load has since been installed under the same key, deleting by key + // alone would evict that one, and every later caller would start another + // full scan instead of joining it. + if (workspacePackagesMemo.get(key) === pending) workspacePackagesMemo.delete(key); + throw err; + }); + workspacePackagesMemo.set(key, pending); + return pending; +} + +async function loadNodeWorkspacePackagesUncached( + repoRoot: string, ): Promise { const scope = await loadWorkspaceScope(repoRoot); const byName = new Map(); const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; let dirsScanned = 0; - while (queue.length > 0) { + while (dirsScanned < queue.length) { if (dirsScanned >= SCAN_MAX_DIRS) { logger.warn( `[node] package.json scan of ${repoRoot} hit the ${SCAN_MAX_DIRS}-directory cap; workspace packages below it will not resolve`, ); break; } - const { dir, depth } = queue.shift()!; - dirsScanned++; + const { dir, depth } = queue[dirsScanned++]!; let entries: import('fs').Dirent[]; try { - entries = await fs.readdir(dir, { withFileTypes: true }); + // Sorted like findWorkspaceRoots: same-depth name collisions resolve first-wins, + // and readdir order is filesystem-dependent. + entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); } catch { continue; } @@ -420,10 +574,34 @@ async function readManifest( const value = parsed[field]; if (typeof value === 'string') push(entries, rebase(value)); } + } + // Entry points that name BUILD OUTPUT (`main: dist/x.js`, `exports: ./dist/…`) + // never match an indexed source file — the package's real source entry has to + // be discovered. keycloak's `@keycloak/keycloak-ui-shared` publishes + // `dist/keycloak-ui-shared.js` and keeps its entry in `vite.config.ts` + // (`build.lib.entry: 'src/main.ts'`); with only the declared fields it had 7 + // resolved calls against ~845 raw. Tried in a fixed order, and ONLY appended + // (declared entries keep precedence): `source` / `publishConfig.source`, the + // vite `lib.entry`, then `src/main` / `src/index`. More than one candidate + // that exists on disk is recorded as ambiguous rather than picked — a wrong + // entry binds every import of the package to the wrong file. + // A package whose `exports` map has no `"."` (only subpaths) refuses the bare + // specifier outright; discovery must not manufacture a `src/index` root for it. + const rootlessExports = declaresExports && rootExports.length === 0; + const discovered = rootlessExports + ? { entries: [], ambiguous: [], allowConventional: false } + : await discoverSourceEntries(parsed, dir, rebase, packageDir, repoRoot); + for (const entry of discovered.entries) push(entries, entry); + if (!declaresExports && discovered.allowConventional) { for (const conventional of ['src/index', 'index', 'lib/index']) { push(entries, joinRepoPath(packageDir, conventional)); } } + if (discovered.ambiguous.length > 0) { + logger.warn( + `[node] package ${name}: ${discovered.ambiguous.length} candidate source entries (${discovered.ambiguous.join(', ')}) — none adopted; declare \`source\` or a single lib entry`, + ); + } const subpathImports = new Map(); collectImports(parsed.imports, subpathImports, rebase); @@ -507,6 +685,204 @@ function collectImports( } } +/** + * Does a declared entry point at build output rather than source? Output + * directories and minified bundles. A plain `.js` is NOT build output on its + * own — `main: "src/index.js"` / `index.js` is a JavaScript package's source, + * and treating every `.js` as output ran discovery for essentially every CJS + * package and could adopt a `src/main` beside the real entry. + */ +function looksLikeBuildOutput(entry: string): boolean { + return /(^|\/)(dist|build|lib|out|esm|cjs|umd)\//.test(entry) || /\.min\.[cm]?js$/.test(entry); +} + +async function discoverSourceEntries( + parsed: Record, + dir: string, + rebase: (raw: string) => string, + packageDir: string, + repoRoot: string, +): Promise<{ entries: string[]; ambiguous: string[]; allowConventional: boolean }> { + const declared: string[] = []; + const exportsRoot = parsed.exports; + if (typeof exportsRoot === 'string') declared.push(exportsRoot); + else if (exportsRoot !== null && typeof exportsRoot === 'object') { + const dot = (exportsRoot as Record)['.']; + if (typeof dot === 'string') declared.push(dot); + else if (dot !== null && typeof dot === 'object') { + for (const v of Object.values(dot as Record)) + if (typeof v === 'string') declared.push(v); + } + } + for (const field of ['module', 'main']) { + const value = parsed[field]; + if (typeof value === 'string') declared.push(value); + } + // Only when every declared entry is build output (or nothing is declared and + // the conventional stems are absent) does discovery run at all. + if (declared.length > 0 && !declared.every(looksLikeBuildOutput)) + return { entries: [], ambiguous: [], allowConventional: true }; + + const candidates: string[] = []; + const source = parsed.source; + if (typeof source === 'string') candidates.push(rebase(source)); + const publishConfig = parsed.publishConfig; + if (publishConfig !== null && typeof publishConfig === 'object') { + const ps = (publishConfig as Record).source; + if (typeof ps === 'string') candidates.push(rebase(ps)); + } + let hasViteConfig = false; + for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) { + try { + const text = await fs.readFile(path.join(dir, cfg), 'utf-8'); + hasViteConfig = true; + const entry = await staticViteEntry(text); + if (entry !== null) push(candidates, rebase(entry)); + } catch { + /* no such config */ + } + } + const existing: string[] = []; + for (const candidate of candidates) { + if (await stemExists(repoRoot, candidate)) push(existing, candidate); + } + // A config we cannot establish is not evidence for a conventional entry. + if (existing.length === 0 && !hasViteConfig) { + for (const conventional of ['src/main', 'src/index']) { + const stem = joinRepoPath(packageDir, conventional); + if (await stemExists(repoRoot, stem)) push(existing, stem); + } + } + const allowConventional = !hasViteConfig && candidates.length === 0; + if (existing.length > 1) return { entries: [], ambiguous: existing, allowConventional }; + return { entries: existing, ambiguous: [], allowConventional }; +} + +/** Read a literal exported build.lib.entry without evaluating repository code. */ +async function staticViteEntry(text: string): Promise { + // Most manifests need no Vite discovery. Keep native grammars off that path. + const [{ default: TreeSitter }, { default: grammar }, { parseSourceSafe }] = await Promise.all([ + import('tree-sitter'), + import('tree-sitter-typescript'), + import('../../tree-sitter/safe-parse.js'), + ]); + const parser = new TreeSitter(); + parser.setLanguage(grammar.typescript); + const tree = parseSourceSafe(parser, text); + if (tree.rootNode.hasError) return null; + const exports = tree.rootNode.namedChildren.filter( + (node) => node.type === 'export_statement' && node.children.some((c) => c.type === 'default'), + ); + if (exports.length !== 1) return null; + let config = exports[0]!.childForFieldName('value'); + if (config?.type === 'call_expression') { + if (config.childForFieldName('function')?.text !== 'defineConfig') return null; + // A locally defined helper need not preserve its argument like Vite does. + if ( + tree.rootNode + .descendantsOfType(['function_declaration', 'variable_declarator']) + .some((node) => node.childForFieldName('name')?.text === 'defineConfig') + ) + return null; + for (const statement of tree.rootNode.namedChildren) { + if (statement.type !== 'import_statement') continue; + if (!statement.descendantsOfType('identifier').some((n) => n.text === 'defineConfig')) + continue; + const source = statement.childForFieldName('source')?.text; + if (source !== "'vite'" && source !== '"vite"') return null; + const identityImport = statement.descendantsOfType('import_specifier').some((specifier) => { + const imported = specifier.childForFieldName('name')?.text; + const local = specifier.childForFieldName('alias')?.text ?? imported; + return imported === 'defineConfig' && local === 'defineConfig'; + }); + if (!identityImport) return null; + } + const args = config + .childForFieldName('arguments') + ?.namedChildren.filter((n) => n.type !== 'comment'); + if (args?.length !== 1) return null; + config = args[0]!; + } + for (const key of ['build', 'lib', 'entry']) config = staticObjectProperty(config, key); + if (config?.type !== 'string' || config.namedChildren.some((n) => n.type === 'escape_sequence')) + return null; + return config.text.slice(1, -1); +} + +/** Reject computed keys, spreads and duplicate properties that could override a value. */ +function staticObjectProperty( + node: Parser.SyntaxNode | null, + key: string, +): Parser.SyntaxNode | null { + if (node?.type !== 'object') return null; + let value: Parser.SyntaxNode | null = null; + for (const member of node.namedChildren) { + if (member.type === 'comment') continue; + if (member.type !== 'pair') return null; + const name = member.childForFieldName('key'); + if (name?.type !== 'property_identifier' && name?.type !== 'string') return null; + if (name.type === 'string' && name.namedChildren.some((n) => n.type === 'escape_sequence')) + return null; + const property = name.type === 'string' ? name.text.slice(1, -1) : name.text; + if (property !== key) continue; + if (value !== null) return null; + value = member.childForFieldName('value'); + } + return value; +} + +/** + * Remove line (`//`) and block comments from JS/TS config text before a regex + * reads it. String contents are preserved (a `//` inside quotes is not a + * comment), so `entry: 'src/index.ts'` survives, as does a comment opener + * written inside a string. + */ +export function stripJsComments(text: string): string { + let out = ''; + let i = 0; + while (i < text.length) { + const ch = text[i]!; + const next = text[i + 1]; + if (ch === '"' || ch === "'" || ch === '`') { + const quote = ch; + let j = i + 1; + while (j < text.length && text[j] !== quote) { + if (text[j] === '\\') j++; + j++; + } + out += text.slice(i, j + 1); + i = j + 1; + } else if (ch === '/' && next === '/') { + const end = text.indexOf('\n', i); + i = end === -1 ? text.length : end; + } else if (ch === '/' && next === '*') { + const end = text.indexOf('*/', i + 2); + i = end === -1 ? text.length : end + 2; + } else { + out += ch; + i++; + } + } + return out; +} + +/** A repo-relative stem exists as a source file (with any TS/JS extension). */ +// The root is threaded explicitly: a module-level "current root" clobbered +// under two concurrent scans and turned an ambiguity refusal into a confident +// wrong entry (the second repo's root made one candidate "not exist"). +async function stemExists(repoRoot: string, stem: string): Promise { + const abs = path.join(repoRoot, stem); + for (const ext of ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']) { + try { + const st = await fs.stat(abs + ext); + if (st.isFile()) return true; + } catch { + /* try next */ + } + } + return false; +} + /** `"./src/index.ts"` -> `"src/index"`; leaves an extension-less path alone. */ function stripEntryPrefixes(entry: string): string { const withoutDot = entry.replace(/^\.\//, '').replace(/^\//, ''); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 89c7c2db4..add86ccd8 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -17,6 +17,7 @@ import { constants as fsConstants } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; import { acquireIndexLock } from '../storage/index-lock.js'; +import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js'; import { logNameFallbackSummary, summarizeNameFallback, @@ -1081,6 +1082,9 @@ export async function runFullAnalysis( // Scope the degraded-parse log throttle to this run (module-level counter // would otherwise stay saturated on a reused process). resetDegradedParseCounter(); + // The workspace-package memo is per process: this run must see the tree as it + // is now, not as the previous run in a long-lived watch/server process saw it. + invalidateNodeWorkspacePackages(repoPath); const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg)); const acquireOpts = { diff --git a/gitnexus/test/integration/resolvers/barrel-workspace-hop.test.ts b/gitnexus/test/integration/resolvers/barrel-workspace-hop.test.ts new file mode 100644 index 000000000..379a67c79 --- /dev/null +++ b/gitnexus/test/integration/resolvers/barrel-workspace-hop.test.ts @@ -0,0 +1,203 @@ +/** + * The workspace-package barrel hop. + * + * A call imported from a workspace package (`@x/ui`) resolves through the + * package's `main`, through three re-export forms, and through a barrel chain + * three levels deep; a call imported through a tsconfig `paths` alias resolves + * too. Those resolutions are pinned here against regression, together with the + * one shape the resolver must REFUSE: two `export *` sources publishing the + * same name (a star-vs-star collision) is recorded as `reexport-ambiguous` + * naming both candidates, and the name appears in the run's census. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; +import { summarizeNameFallback } from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js'; + +describe('workspace-package barrel hop', () => { + let result: PipelineResult; + let repoDir: string | undefined; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1c-barrel-')); + writeFixtureRepo(repoDir, { + 'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n', + 'packages/ui/package.json': + '{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n', + // Three re-export forms in one barrel, plus a nested barrel chain. + 'packages/ui/src/index.ts': `export { Button } from './components/Button/Button'; +export * from './components/Stack'; +export type { ButtonProps } from './components/Button/Button'; +export * from './themes'; +`, + 'packages/ui/src/components/Button/Button.tsx': `export interface ButtonProps { label: string } +export function Button(props: ButtonProps) { + return props.label; +} +`, + 'packages/ui/src/components/Stack/index.ts': `export * from './Stack';\n`, + 'packages/ui/src/components/Stack/Stack.tsx': `export function Stack(children: string) { + return children; +} +`, + // themes -> hooks -> useStyles2: three barrels deep. + 'packages/ui/src/themes/index.ts': `export * from './hooks';\n`, + 'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`, + 'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) { + return fn('theme'); +} +`, + 'packages/app/package.json': + '{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n', + 'packages/app/tsconfig.json': `{ + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": "src", + "paths": { "app/core/*": ["core/*"] } + } +} +`, + 'packages/app/src/core/utils/format.ts': `export function formatTitle(raw: string) { + return raw.trim(); +} +`, + // Calls AND JSX through the same imported names. + 'packages/app/src/features/Panel.tsx': `import { Button, Stack, useStyles2 } from '@x/ui'; +import { formatTitle } from 'app/core/utils/format'; + +export function Panel() { + const styles = useStyles2((t) => t); + const title = formatTitle(' hi '); + const label = Button({ label: title }); + const stacked = Stack(label); + return