/// /** * An app's catalogue (UIG-13): every part in an app's own code, what it is for, * where it is used, and which of four kinds it is. * * **Built fresh, never committed** (Katerina, 18 September 2026). The package is * public and Peek and Ship are private, so an app's catalogue lives nowhere but * in its own code: `estiva-ui find` and `estiva-ui check` build it each time, * which takes about a second, and `estiva-ui build` writes it to a file only * when asked. A file that is never kept can never drift. * * **A file is not a part, and a folder says nothing.** Peek's `components/ui` * holds one-line pass-ons beside 187-line components, one file can hold six * parts, and a part can draw nothing at all (a provider, a listener). So the * target set is every name an app's `.tsx` files export that is a part: a * function that draws, or a name drawn as a tag somewhere in the app. The kind * is read from the code, and only from the code: * * - **re-export** — its file only hands a package part on. * - **one-off** — one file of the app uses it. * - **reusable** — two or more do. * - **promote-candidate** — two or more do, and everything it uses is already in * the package, so it could move there as it is. Katerina rules on each. * - **unused** — no file of the app uses it; its stories or tests may. * * Where the count is wrong — a general part only one screen uses yet — a person * writes the kind beside the part, with the reason, and the catalogue shows it: * * /** * * A timeline of what happened to a thing. * * @registry reusable: general; one screen uses it so far * *\/ * * A part that shares its name with a package part is refused unless the reason * it is kept is written beside it, the same way: * * /** * * The package's `Avatar`, handed the picture this app knows about. * * @registry namesake: the package cannot know where pictures come from * *\/ * * Peek's own MembersPill stayed in two headers for two days after the package * gained one (0.58.0, 6 October) — built from the package's parts, so no rule * saw it (Katerina, 8 October). * * TypeScript is the app's own install, as it is for the package's builder. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, isBaseUi, rootsIn, splitTag } from './behaviours' import { firstSentence, isPackageImport, pageOpening, readModule, sanitize, toId, type Resolved } from './build' import { looksOf, parseForLooks } from '../eslint/looks-of' import { SCHEMA_VERSION, type AppFacts, type EntryClass, type FileWithoutPart, type Registry, type RegistryEntry } from './schema' export interface AppBuildOptions { /** The app's folder: where its `package.json` and `src/` are. Ship's is `web`. */ root?: string /** The name its entries are recorded under — `peek`, `ship`. Defaults to the app's package name. */ repo?: string /** The package's own catalogue, for the parts an app part shares a name with. Defaults to the one this builder ships beside. */ packageRegistry?: Registry | null /** The packages the package itself depends on. An import of one never ties a part to its app. Defaults to the package's own. */ packageDependencies?: string[] } /** The kinds a person may write beside a part. A pass-on is a fact about its file; so is being used nowhere. */ const WRITABLE: EntryClass[] = ['reusable', 'one-off', 'promote-candidate'] const WRITTEN = /^@registry\s+(?!namesake\b)([a-z-]+)\s*:\s*(.+)$/m /** Why a part with a package part's name is kept: `@registry namesake: `. */ const NAMESAKE = /^@registry\s+namesake\s*:\s*\S/m const isStory = (file: string) => /\.stories\.[cm]?[jt]sx?$/.test(file) const isTest = (file: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)__(tests|mocks)__\//.test(file) const isPascal = (name: string) => /^[A-Z][A-Za-z0-9]*$/.test(name) && !/^[A-Z0-9_]+$/.test(name) /** * What a dynamic `import('./x')` takes from its file. Written as * `import('./Settings').then((m) => ({ default: m.SettingsPage }))`, only the names * read off the module; otherwise the whole module (`*`), whose default a caller * then takes. */ function pickedFrom(call: ts.CallExpression): { imported: string; local: string }[] { const then = call.parent const invoked = then?.parent if (then && ts.isPropertyAccessExpression(then) && then.name.text === 'then' && invoked && ts.isCallExpression(invoked)) { const callback = invoked.arguments[0] const param = callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ? callback.parameters[0]?.name : undefined if (param && ts.isIdentifier(param)) { const names = new Set() const visit = (n: ts.Node) => { if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === param.text) names.add(n.name.text) ts.forEachChild(n, visit) } visit(callback) if (names.size) return [...names].map((imported) => ({ imported, local: '(dynamic)' })) } } return [{ imported: '*', local: '(dynamic)' }] } /** * `export default memo(TopicRow)`: the default is TopicRow, wrapped. A call on * a name, with no function written into it, hands that name out, so the export * is read as if it named it — one part, whose comment and props are its own. */ function unwrapDefault(ex: T): T { if (!ex.isDefault || ex.local || !ex.node || !ts.isExpression(ex.node)) return ex const name = handedOut(ex.node) return name ? { ...ex, local: name, node: null } : ex } /** * The name an `export default …` hands out, under whatever it is wrapped in: * `memo(forwardRef(Row))`, `Row as ComponentType`, `Row satisfies FC`, * `styled(Row)\`…\``. `null` when what is exported is written right there. */ function handedOut(expression: ts.Expression): string | null { let e: ts.Expression = expression for (;;) { if (ts.isIdentifier(e)) return e.text if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression else if (ts.isTaggedTemplateExpression(e)) e = e.tag else if (ts.isCallExpression(e) && e.arguments[0] && !e.arguments.some((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a))) e = e.arguments[0] else return null } } /** `memo-page` → `MemoPage`, `topic_view` → `TopicView`. */ const pascalOf = (stem: string) => stem.split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join('') /** A default with no name of its own takes its file's — or, for `settings/index.tsx`, its folder's. */ const defaultNameOf = (file: string) => { const stem = basename(file, extname(file)) return pascalOf(stem === 'index' ? basename(dirname(file)) : stem) } const CODE = ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts'] /** `@scope/name/sub` → `@scope/name`; `name/sub` → `name`. */ function packageOf(specifier: string): string { const parts = specifier.split('/') return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] } /** What the package this builder belongs to depends on, and its catalogue. Read from beside the builder. */ function packageSelf(): { dependencies: string[]; registry: Registry | null } { const at = (rel: string) => new URL(rel, import.meta.url) const manifest = JSON.parse(readFileSync(at('../../package.json'), 'utf8')) as { name: string; dependencies?: Record; peerDependencies?: Record } const registryUrl = at('../../registry.json') const registry = existsSync(registryUrl) ? (JSON.parse(readFileSync(registryUrl, 'utf8')) as Registry) : null return { dependencies: [manifest.name, ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})], registry } } /** * The import aliases an app's `tsconfig*.json` files declare — `@/*` → * `/src/*` — and the `baseUrl` folders a bare `src/Card` is read from. */ function readAliases(root: string): { aliases: { prefix: string; target: string }[]; bases: string[] } { const aliases: { prefix: string; target: string }[] = [] const bases: string[] = [] for (const name of readdirSync(root).filter((file) => /^tsconfig.*\.json$/.test(file)).sort()) { const read = ts.readConfigFile(join(root, name), ts.sys.readFile) const options = (read.config?.compilerOptions ?? {}) as { paths?: Record; baseUrl?: string } const base = resolve(root, options.baseUrl ?? '.') if (options.baseUrl !== undefined && !bases.includes(base)) bases.push(base) for (const [pattern, targets] of Object.entries(options.paths ?? {})) { if (!pattern.endsWith('/*') || !targets[0]?.endsWith('/*')) continue const prefix = pattern.slice(0, -1) if (!aliases.some((alias) => alias.prefix === prefix)) aliases.push({ prefix, target: resolve(base, targets[0].slice(0, -2)) }) } } return { aliases, bases } } function walk(dir: string): string[] { return readdirSync(dir).flatMap((name) => { if (name === 'node_modules' || name.startsWith('.')) return [] const path = join(dir, name) return statSync(path).isDirectory() ? walk(path) : [path] }) } /** One import, re-export or dynamic import of a file, resolved. */ interface Link { specifier: string /** An app code file, relative to the app; `null` for a package; `'?'` for anything else. */ target: string | null /** `imported` name → local name; `*` for a namespace or a dynamic import. Type-only names are left out. */ names: { imported: string; local: string }[] typeOnly: boolean kind: 'import' | 're-export' | 'dynamic' } /** A part a file exports, before anything is known about its use. */ interface Found { file: string /** The name it is exported as; a default export takes its own name, or the file's. */ name: string /** The name it has inside its file, where it has one. */ local: string | null defaultExport: boolean /** A pass-on: the package and the name it hands on. */ from: { specifier: string; name: string } | null } /** Everything one file says, read once. */ interface FileFacts { file: string source: string sf: ts.SourceFile links: Link[] /** `exported name` → local name, and whether it is the default. */ exports: { name: string; local: string | null; isDefault: boolean; node: ts.Node | null }[] /** Pass-ons and barrel entries: `export { A } from '…'`. */ reexports: { name: string; source: string; specifier: string; target: string | null }[] starFrom: { specifier: string; target: string | null }[] /** `export * as Parts from './parts'`: a name that stands for another file's parts. */ namespaces: { name: string; target: string | null }[] /** Every name drawn as a JSX tag in this file. */ tags: Set /** Each imported value name → where it came from, for `import { X } from '…'` then `export { X }`. */ bindings: Map } /** Whether a node draws: JSX, or `createElement` / `createPortal`. */ function draws(node: ts.Node): boolean { let found = false const visit = (n: ts.Node) => { if (found) return if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { found = true return } if (ts.isCallExpression(n)) { const callee = ts.isPropertyAccessExpression(n.expression) ? n.expression.name.text : ts.isIdentifier(n.expression) ? n.expression.text : '' if (callee === 'createElement' || callee === 'createPortal') { found = true return } } ts.forEachChild(n, visit) } visit(node) return found } /** * Whether a file names `local` anywhere but in `home` (its own declaration), its * imports and its export lines — as a tag, a value, or passed along. A property * name, an attribute name and a type are not uses of the part. */ function mentions(sf: ts.SourceFile, local: string, home: ts.Node | null): boolean { let named = false const visit = (n: ts.Node) => { if (named) return if (ts.isIdentifier(n) && n.text === local) { const parent = n.parent // The name of something else that happens to be spelled the same — a // property of a type or a class, a member, another declaration — is not the part. const declares = (ts.isPropertySignature(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodDeclaration(parent) || ts.isMethodSignature(parent) || ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent) || ts.isEnumMember(parent) || ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent) || ts.isClassDeclaration(parent) || ts.isParameter(parent) || ts.isBindingElement(parent)) && (parent as { name?: ts.Node }).name === n const bindingKey = ts.isBindingElement(parent) && parent.propertyName === n const isName = declares || bindingKey || (ts.isPropertyAccessExpression(parent) && parent.name === n) || (ts.isPropertyAssignment(parent) && parent.name === n) || ts.isJsxAttribute(parent) || ts.isExportSpecifier(parent) || ts.isImportSpecifier(parent) || ts.isJsxClosingElement(parent) // `SlashMenu.displayName = 'SlashMenu'` sets something on the part; it does not use it. const configures = ts.isPropertyAccessExpression(parent) && parent.expression === n && ts.isBinaryExpression(parent.parent) && parent.parent.left === parent && parent.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken if (configures) { ts.forEachChild(n, visit) return } let inType = false for (let a: ts.Node | undefined = parent; a && a !== sf; a = a.parent) if (ts.isTypeNode(a)) inType = true if (!isName && !inType && !shadowed(n, local, sf)) named = true } ts.forEachChild(n, visit) } for (const s of sf.statements) { if (s === home || ts.isExportDeclaration(s) || ts.isImportDeclaration(s)) continue // `export default Row`, `memo(forwardRef(Row))`, `Row as ComponentType` hand the // part out; they do not use it. Anything else written into the default is code. if (ts.isExportAssignment(s) && handedOut(s.expression)) continue visit(s) } return named } /** Whether a binding name — `Icon`, `{ icon: Icon }`, `[Icon]` — binds `local`. */ function binds(name: ts.BindingName | undefined, local: string): boolean { if (!name) return false if (ts.isIdentifier(name)) return name.text === local return name.elements.some((element) => !ts.isOmittedExpression(element) && binds(element.name, local)) } /** * Whether `n` means something declared closer than the file: a parameter * (`Item({ icon: Icon }) { return }`), or a constant, function or class * of an enclosing block. That `Icon` is not the file's part called Icon. */ function shadowed(n: ts.Node, local: string, sf: ts.SourceFile): boolean { for (let a: ts.Node | undefined = n.parent; a && a !== sf; a = a.parent) { if (ts.isFunctionLike(a)) { if (a.parameters.some((p) => binds(p.name, local))) return true if ((ts.isFunctionExpression(a) || ts.isClassExpression(a)) && a.name?.text === local) return true } if (ts.isCatchClause(a) && binds(a.variableDeclaration?.name, local)) return true if (ts.isBlock(a) || ts.isModuleBlock(a) || ts.isCaseClause(a) || ts.isDefaultClause(a)) { for (const s of a.statements) { if (ts.isVariableStatement(s) && s.declarationList.declarations.some((d) => binds(d.name, local))) return true if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && s.name?.text === local) return true } } if ((ts.isForStatement(a) || ts.isForOfStatement(a) || ts.isForInStatement(a)) && a.initializer && ts.isVariableDeclarationList(a.initializer) && a.initializer.declarations.some((d) => binds(d.name, local))) return true } return false } /** * The thing a declaration holds, if it is a function, a class, or a call that * wraps one (`forwardRef`, `memo`) — as a constant, or written straight into * `export default …`. */ function drawingBody(node: ts.Node): ts.Node | null { if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return node const init = ts.isVariableDeclaration(node) ? node.initializer : ts.isExpression(node) ? node : undefined if (!init) return null if (ts.isArrowFunction(init) || ts.isFunctionExpression(init) || ts.isClassExpression(init)) return init if (ts.isCallExpression(init)) { const callee = init.expression.getText() if (/(^|\.)createContext$/.test(callee)) return null if (init.arguments.some((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a))) return init } return null } /** * Read one app and return its catalogue. * * Throws on anything it cannot explain, with every problem at once: a part with * no purpose, a written kind it does not know. A catalogue that quietly drops * what it could not read would answer "we have nothing for that" when the * truth is "the builder gave up". */ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry, packageDependencies }: AppBuildOptions = {}): Registry { const app = resolve(root) const src = join(app, 'src') if (!existsSync(src)) throw new Error(`${app} has no src/ folder: estiva-ui reads an app's parts from src/`) const manifest = JSON.parse(readFileSync(join(app, 'package.json'), 'utf8')) as { name?: string; version?: string; scripts?: Record } const self = packageRegistry === undefined || packageDependencies === undefined ? packageSelf() : null const theRegistry = packageRegistry === undefined ? (self?.registry ?? null) : packageRegistry const allowed = new Set(packageDependencies ?? self?.dependencies ?? []) const repoName = repo ?? manifest.name ?? basename(app) const { aliases, bases } = readAliases(app) const rel = (abs: string) => relative(app, abs).split(sep).join('/') const files = walk(src) .filter((path) => CODE.includes(extname(path)) && !path.endsWith('.d.ts')) .map(rel) .sort() const known = new Set(files) /** Where an import points: an app code file, a package (`null`), or something else (`'?'`). */ const resolveSpecifier = (from: string, specifier: string): string | null => { let base: string | null = null if (specifier.startsWith('.')) base = resolve(app, dirname(from), specifier) else { const alias = aliases.find((a) => specifier.startsWith(a.prefix)) if (alias) base = join(alias.target, specifier.slice(alias.prefix.length)) } const lookUp = (at: string): string | null => { const stem = at.replace(/\.(js|jsx)$/, '') for (const candidate of [at, ...CODE.map((e) => stem + e), ...CODE.map((e) => join(at, `index${e}`))]) { const r = rel(candidate) if (known.has(r)) return r } return null } if (base === null) { // A bare `src/Card` is the app's own when its tsconfig's baseUrl says so; otherwise a package. for (const folder of bases) { const hit = lookUp(join(folder, specifier)) if (hit) return hit } return null } return lookUp(base) ?? '?' } // ── Read every file once ──────────────────────────────────────────────── const facts = new Map() for (const file of files) { const source = readFileSync(join(app, file), 'utf8') const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, /x$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS) const links: Link[] = [] const exports: FileFacts['exports'] = [] const reexports: FileFacts['reexports'] = [] const starFrom: FileFacts['starFrom'] = [] const namespaces: FileFacts['namespaces'] = [] for (const statement of sf.statements) { if (ts.isImportDeclaration(statement)) { if (!ts.isStringLiteral(statement.moduleSpecifier)) continue const specifier = statement.moduleSpecifier.text const clause = statement.importClause const names: Link['names'] = [] if (clause && !clause.isTypeOnly) { if (clause.name) names.push({ imported: 'default', local: clause.name.text }) const bindings = clause.namedBindings if (bindings && ts.isNamedImports(bindings)) for (const e of bindings.elements) if (!e.isTypeOnly) names.push({ imported: (e.propertyName ?? e.name).text, local: e.name.text }) if (bindings && ts.isNamespaceImport(bindings)) names.push({ imported: '*', local: bindings.name.text }) } links.push({ specifier, target: resolveSpecifier(file, specifier), names, typeOnly: !!clause?.isTypeOnly || (clause !== undefined && names.length === 0 && !!clause.namedBindings), kind: 'import' }) continue } if (ts.isExportDeclaration(statement)) { const specifier = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null if (specifier === null) { if (!statement.isTypeOnly && statement.exportClause && ts.isNamedExports(statement.exportClause)) { // `export { Page as default }` is the file's default, named Page. for (const e of statement.exportClause.elements) if (!e.isTypeOnly) exports.push({ name: e.name.text, local: (e.propertyName ?? e.name).text, isDefault: e.name.text === 'default', node: null }) } continue } const target = resolveSpecifier(file, specifier) links.push({ specifier, target, names: [], typeOnly: statement.isTypeOnly, kind: 're-export' }) if (statement.isTypeOnly) continue if (!statement.exportClause) starFrom.push({ specifier, target }) else if (ts.isNamedExports(statement.exportClause)) { for (const e of statement.exportClause.elements) if (!e.isTypeOnly) reexports.push({ name: e.name.text, source: (e.propertyName ?? e.name).text, specifier, target }) } else if (ts.isNamespaceExport(statement.exportClause)) { // `export * as Parts from './parts'`: a caller's `Parts.Card` is the parts file's Card. namespaces.push({ name: statement.exportClause.name.text, target }) } continue } if (ts.isExportAssignment(statement) && !statement.isExportEquals) { const e = statement.expression exports.push({ name: 'default', local: ts.isIdentifier(e) ? e.text : null, isDefault: true, node: ts.isIdentifier(e) ? null : e }) continue } const mods = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : [] if (!mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue const isDefault = mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) // An overload's signature is not a part of its own: the part is the one with a body. if (ts.isFunctionDeclaration(statement) && !statement.body) continue if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { exports.push({ name: isDefault ? 'default' : (statement.name?.text ?? 'default'), local: statement.name?.text ?? null, isDefault, node: statement }) } else if (ts.isVariableStatement(statement)) { for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name)) exports.push({ name: d.name.text, local: d.name.text, isDefault: false, node: d }) } } const tags = new Set() const visit = (n: ts.Node) => { if (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.ImportKeyword && n.arguments[0] && ts.isStringLiteralLike(n.arguments[0])) { const specifier = n.arguments[0].text links.push({ specifier, target: resolveSpecifier(file, specifier), names: pickedFrom(n), typeOnly: false, kind: 'dynamic' }) } if ((ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && ts.isIdentifier(n.tagName)) tags.add(n.tagName.text) ts.forEachChild(n, visit) } visit(sf) const bindings: FileFacts['bindings'] = new Map() for (const link of links) if (link.kind === 'import') for (const n of link.names) if (n.imported !== '*') bindings.set(n.local, { specifier: link.specifier, imported: n.imported, target: link.target }) facts.set(file, { file, source, sf, links, exports, reexports, starFrom, namespaces, tags, bindings }) } const drawnAnywhere = new Set() for (const f of facts.values()) for (const tag of f.tags) drawnAnywhere.add(tag) /** A top-level declaration of a file, by its local name. */ const declarationOf = (f: FileFacts, local: string): ts.Node | null => { for (const s of f.sf.statements) { // Past an overload's signature, to the function with a body. if (ts.isFunctionDeclaration(s) && s.name?.text === local && !s.body) continue if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && s.name?.text === local) return s if (ts.isVariableStatement(s)) for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === local) return d } return null } // ── The parts ─────────────────────────────────────────────────────────── const problems: string[] = [] const found: Found[] = [] const targets = files.filter((file) => file.endsWith('.tsx') && !isStory(file) && !isTest(file)) for (const file of targets) { const f = facts.get(file)! for (const written of f.exports) { const ex = unwrapDefault(written) // `export default memo(Avatar)` with Avatar imported: not Avatar handed on, but a // new part of this file that wraps it, named after the file. // Only a name that is a part's: `createBrowserRouter(routes)` hands out no part. if (ex !== written && ex.local && isPascal(ex.local) && !declarationOf(f, ex.local) && f.bindings.has(ex.local)) { found.push({ file, name: defaultNameOf(file), local: null, defaultExport: true, from: null }) continue } // `export function App` and `export default App` — or `export default memo(App)`: one part, not two. if (ex.isDefault && ex.local && f.exports.some((other) => !other.isDefault && other.local === ex.local)) continue const node = ex.node ?? (ex.local ? declarationOf(f, ex.local) : null) // A default with no name of its own takes its file's, as a name: `memo-page.tsx` → MemoPage. const own = ex.local ?? (ex.isDefault ? defaultNameOf(file) : ex.name) const name = ex.isDefault ? own : ex.name if (!isPascal(name)) continue // `import { SkeletonBar } from '@estiva-app/ui'` and later `export { SkeletonBar }`: // handed on, exactly as `export { SkeletonBar } from '@estiva-app/ui'` is. // From another file of the app it is a barrel, and the part is listed there. const bound = node === null && ex.local ? f.bindings.get(ex.local) : undefined if (bound) { if (bound.target === null) found.push({ file, name, local: null, defaultExport: ex.isDefault, from: { specifier: bound.specifier, name: bound.imported } }) continue } // `export const SkeletonSidebarList = SkeletonList`, SkeletonList imported: the // package's part under a second name, handed on. From an app file, a barrel. const alias = node && ts.isVariableDeclaration(node) && node.initializer && ts.isIdentifier(node.initializer) ? f.bindings.get(node.initializer.text) : undefined if (alias) { if (alias.target === null) found.push({ file, name, local: null, defaultExport: ex.isDefault, from: { specifier: alias.specifier, name: alias.imported } }) continue } const body = node ? drawingBody(node) : null const isPart = (body !== null && draws(body)) || drawnAnywhere.has(ex.local ?? name) if (isPart) found.push({ file, name, local: ex.local, defaultExport: ex.isDefault, from: null }) } for (const re of f.reexports) { // Handing on another file of the app is a barrel: the part is listed where it is written. if (re.target !== null || !isPascal(re.name)) continue found.push({ file, name: re.name, local: null, defaultExport: false, from: { specifier: re.specifier, name: re.source } }) } for (const star of f.starFrom) { if (star.target === null) problems.push(`${file} hands on everything from '${star.specifier}' with export *: name the parts it hands on, so the catalogue can list them`) } } // ── Where each is used ────────────────────────────────────────────────── const key = (file: string, name: string) => `${file}#${name}` const byKey = new Map(found.map((p) => [key(p.file, p.name), p])) const partsIn = (file: string) => found.filter((p) => p.file === file) /** The part a file's default export is, however it was written. */ const defaultPartOf = (file: string): Found | null => { const written = facts.get(file)?.exports.find((e) => e.isDefault) if (!written) return null const d = unwrapDefault(written) return partsIn(file).find((p) => p.defaultExport || (d.local !== null && p.local === d.local)) ?? null } /** What `name`, imported from `file`, really is: follows barrels to the file that writes it. */ const origin = (file: string, name: string, seen = new Set()): Found | null => { if (seen.has(key(file, name))) return null seen.add(key(file, name)) const direct = byKey.get(key(file, name)) if (direct) return direct const f = facts.get(file) if (!f) return null // `export default Foo` elsewhere in the file: the default is Foo's part. if (name === 'default') { const d = defaultPartOf(file) if (d) return d } const re = f.reexports.find((r) => r.name === name) if (re && re.target && re.target !== '?') return origin(re.target, re.source, seen) // `import { X } from './X'` and `export { X }`: a barrel written in two lines. const ex = f.exports.find((e) => (name === 'default' ? e.isDefault : e.name === name && !e.isDefault)) const renamed = ex?.node && ts.isVariableDeclaration(ex.node) && ex.node.initializer && ts.isIdentifier(ex.node.initializer) ? f.bindings.get(ex.node.initializer.text) : undefined const bound = (ex?.local && !ex.node && !declarationOf(f, ex.local) ? f.bindings.get(ex.local) : undefined) ?? renamed if (bound?.target && bound.target !== '?') return origin(bound.target, bound.imported, seen) for (const star of f.starFrom) if (star.target && star.target !== '?') { const hit = origin(star.target, name, seen) if (hit) return hit } return null } /** Every part a file hands out, through its barrels too. */ const everyPartOf = (file: string, seen = new Set()): Found[] => { if (seen.has(file)) return [] seen.add(file) const f = facts.get(file) if (!f) return [] const out = [...partsIn(file)] for (const re of f.reexports) if (re.target && re.target !== '?') { const o = origin(re.target, re.source); if (o) out.push(o) } for (const star of f.starFrom) if (star.target && star.target !== '?') out.push(...everyPartOf(star.target, seen)) for (const ns of f.namespaces) if (ns.target && ns.target !== '?') out.push(...everyPartOf(ns.target, seen)) return out } /** The parts a namespace export stands for, followed through barrels. */ const namespaceParts = (file: string, name: string, seen = new Set()): Found[] => { if (seen.has(`${file}#${name}`)) return [] seen.add(`${file}#${name}`) const f = facts.get(file) if (!f) return [] const ns = f.namespaces.find((x) => x.name === name) if (ns) return ns.target && ns.target !== '?' ? everyPartOf(ns.target) : [] const re = f.reexports.find((r) => r.name === name) if (re?.target && re.target !== '?') return namespaceParts(re.target, re.source, seen) return f.starFrom.flatMap((star) => (star.target && star.target !== '?' ? namespaceParts(star.target, name, seen) : [])) } const users = new Map; stories: Set; tests: Set }>() for (const p of found) users.set(key(p.file, p.name), { app: new Set(), stories: new Set(), tests: new Set() }) for (const f of facts.values()) { const bucket = (p: Found) => { const u = users.get(key(p.file, p.name))! return isStory(f.file) ? u.stories : isTest(f.file) ? u.tests : u.app } for (const link of f.links) { if (link.kind === 're-export' || link.typeOnly || !link.target || link.target === '?') continue for (const n of link.names) { if (n.imported === '*') { // `lazy(() => import('./Page'))` loads the default; a namespace may use any. const theDefault = link.kind === 'dynamic' ? defaultPartOf(link.target) : null const chosen = theDefault ? [theDefault] : everyPartOf(link.target) for (const p of chosen) if (p.file !== f.file) bucket(p).add(f.file) continue } const p = origin(link.target, n.imported) // A file that imports a part only to export it again hands it on; it does not use it. const handsOn = f.exports.some((e) => e.local === n.local && e.node === null) && !mentions(f.sf, n.local, null) if (handsOn) continue if (p) { if (p.file !== f.file) bucket(p).add(f.file) continue } // `import { Parts } from './barrel'`, where the barrel says `export * as Parts from './parts'`. for (const part of namespaceParts(link.target, n.imported)) if (part.file !== f.file) bucket(part).add(f.file) } } } // Its own file uses it when something there, other than its own declaration and // the export line, names it — a card drawn by the list beside it. for (const p of found) { if (p.from || !p.local) continue const f = facts.get(p.file)! const decl = declarationOf(f, p.local) const home = decl ? (ts.isVariableDeclaration(decl) ? decl.parent.parent : decl) : null if (mentions(f.sf, p.local, home)) users.get(key(p.file, p.name))!.app.add(p.file) } // ── What ties each file to the app ───────────────────────────────────── // A file is tied when it imports a package the package does not itself depend // on, something that is not code, or an app file that is tied. Types count: a // part typed by Peek's data is Peek's. const directTies = new Map() const appDeps = new Map() for (const f of facts.values()) { const ties: string[] = [] const deps: string[] = [] for (const link of f.links) { if (link.target === null) { const tie = link.specifier.startsWith('node:') ? link.specifier : allowed.has(packageOf(link.specifier)) ? null : packageOf(link.specifier) if (tie && !ties.includes(tie)) ties.push(tie) } else if (link.target === '?') { if (!ties.includes(link.specifier)) ties.push(link.specifier) } else if (link.target !== f.file && !deps.includes(link.target)) deps.push(link.target) } directTies.set(f.file, ties) appDeps.set(f.file, deps) } const tied = new Set([...directTies].filter(([, t]) => t.length > 0).map(([file]) => file)) for (let changed = true; changed; ) { changed = false for (const [file, deps] of appDeps) if (!tied.has(file) && deps.some((d) => tied.has(d))) (tied.add(file), (changed = true)) } const tiesOf = (file: string) => [...directTies.get(file)!, ...appDeps.get(file)!.filter((d) => tied.has(d))].sort() // ── Stories: which one shows a part ───────────────────────────────────── const stories = files.filter(isStory).map((file) => { const f = facts.get(file)! let title: string | null = null let component: string | null = null let noDocs = false const names: string[] = [] for (const s of f.sf.statements) { if (!ts.isVariableStatement(s)) continue const exported = (ts.getModifiers(s) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword) for (const d of s.declarationList.declarations) { if (!ts.isIdentifier(d.name)) continue if (d.name.text === 'meta') { let init = d.initializer while (init && (ts.isSatisfiesExpression(init) || ts.isAsExpression(init) || ts.isParenthesizedExpression(init))) init = init.expression if (init && ts.isObjectLiteralExpression(init)) { for (const prop of init.properties) { if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue if (prop.name.text === 'title' && ts.isStringLiteralLike(prop.initializer)) title = prop.initializer.text if (prop.name.text === 'component' && ts.isIdentifier(prop.initializer)) component = prop.initializer.text if (prop.name.text === 'tags' && ts.isArrayLiteralExpression(prop.initializer)) noDocs = prop.initializer.elements.some((e) => ts.isStringLiteralLike(e) && e.text === '!autodocs') } } } else if (exported) names.push(d.name.text) } } // Which part each local name is, through the imports. const imported = new Map() for (const link of f.links) { if (!link.target || link.target === '?') continue for (const n of link.names) { if (n.imported === '*') continue const p = origin(link.target, n.imported) if (p) imported.set(n.local, p) } } return { file, title, component: component ? (imported.get(component) ?? null) : null, noDocs, names, imports: [...imported.values()] } }) const storyOf = (p: Found) => { const stem = (file: string) => basename(file).replace(/\.stories\.[cm]?[jt]sx?$/, '') const own = basename(p.file, extname(p.file)) // A story file named after the part draws it even when its stories are of a // presentational half in the same file — `FilesPanel.stories.tsx` drawing // `FilesPanelView` is FilesPanel's picture, and its page attaches there (UIG-19). const sameFile = (s: (typeof stories)[number]) => s.imports.some((q) => q.file === p.file) const candidates = stories.filter((s) => s.title && s.names.length && (s.component === p || (s.imports.includes(p) && (stem(s.file) === p.name || stem(s.file) === own)) || (stem(s.file) === p.name && sameFile(s)))) candidates.sort((a, b) => Number(b.component === p) - Number(a.component === p) || a.file.localeCompare(b.file)) const s = candidates[0] if (!s || !s.title) return { docsId: null, storyId: null } return { docsId: s.noDocs ? null : `${sanitize(s.title)}--docs`, storyId: toId(s.title, s.names.includes(p.name) ? p.name : s.names[0]) } } // ── Props, read the way the package's are ────────────────────────────── type Module = ReturnType const modules = new Map() const moduleAt = (file: string): Module => { const held = modules.get(file) if (held) return held const read = readModule(facts.get(file)!.source, (specifier, typeName): Resolved | undefined => { // A wrapper typed by the package's own props takes what the package's part // takes. The package names every part's props type after the part — // `SearchInputProps` is `SearchInput`'s, all 65 of them — so its entry answers. if (isPackageImport(specifier)) { const part = typeName.endsWith('Props') ? theRegistry?.entries.find((entry) => entry.name === typeName.slice(0, -'Props'.length)) : undefined return part ? { props: part.props, variants: part.variants } : undefined } const target = resolveSpecifier(file, specifier) return target && target !== '?' ? moduleAt(target).resolveName(typeName) : undefined // A comment directly above a part is that part's, first in its file or not. }, { lendFirstComment: false }) modules.set(file, read) return read } // ── The entries ──────────────────────────────────────────────────────── const namesakes = new Set((theRegistry?.entries ?? []).map((e) => e.name)) const importPathOf = (file: string) => { const abs = join(app, file) const alias = aliases.find((a) => abs.startsWith(a.target + sep)) const stem = file.replace(/\.[cm]?[jt]sx?$/, '').replace(/\/index$/, '') return alias ? `${alias.prefix}${relative(alias.target, join(app, stem)).split(sep).join('/')}` : stem } const port = /(?:-p|--port)[\s=]+(\d+)/.exec(manifest.scripts?.storybook ?? '')?.[1] ?? '6006' // What each part owns, from what it draws (B9): a pass-on owns what its package // part owns; any other part what the whole of each part it draws does, followed // through the app's own parts, and what the Base UI parts it draws do. const packageOwns = new Map((theRegistry?.entries ?? []).map((e) => [e.name, e.ownsBehaviours.map((b) => b.id)])) const drawnByFile = new Map>; roots: Map> }>() const owned = new Map>() const ownsOf = (p: Found, seen: Set): Set => { const at = key(p.file, p.name) const held = owned.get(at) if (held) return held const out = new Set() if (p.from) { if (isPackageImport(p.from.specifier)) for (const id of packageOwns.get(p.from.name) ?? []) out.add(id) return out } const f = facts.get(p.file) if (!f || !p.local || seen.has(at)) return out seen.add(at) if (!drawnByFile.has(p.file)) drawnByFile.set(p.file, { drawn: drawnIn(f.sf), roots: rootsIn(f.sf) }) const { drawn, roots } = drawnByFile.get(p.file)! const imported = new Map(f.links.filter((l) => l.kind === 'import').flatMap((l) => l.names.map((n) => [n.local, { link: l, imported: n.imported }] as const))) // The Base UI parts it draws, anywhere in it (rare in an app: the gate refuses most). for (const tag of drawn.get(p.local) ?? []) { const [local, piece] = splitTag(tag) const from = imported.get(local) if (from && isBaseUi(from.link.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id) } // The parts it is: what it hands back at its root, the package's or the app's own. for (const tag of roots.get(p.local) ?? []) { const from = imported.get(splitTag(tag)[0]) if (!from) continue let inner: Iterable = [] if (isPackageImport(from.link.specifier)) inner = handedOn(from.imported, packageOwns.get(from.imported) ?? []) else if (from.link.target && from.link.target !== '?') { const part = origin(from.link.target, from.imported) if (part) inner = handedOn(part.from?.name ?? part.name, ownsOf(part, seen)) } for (const id of inner) out.add(id) } if (seen.size === 1) owned.set(at, out) seen.delete(at) return out } const entries: RegistryEntry[] = [] for (const p of found) { const u = users.get(key(p.file, p.name))! const usedIn = [...u.app].sort() const pass = p.from const onePart = partsIn(p.file).filter((q) => !q.from).length === 1 const module = pass ? null : moduleAt(p.file) // A default written as an expression has no name in its file: the builder keeps it as `default`. const declared = pass ? undefined : module!.declarations.get(p.local ?? 'default') const header = module?.headerDoc ?? '' // The file's default may carry its comment on `export default memo(Row)` rather than on Row. const doc = declared?.doc || (defaultPartOf(p.file) === p ? (module?.declarations.get('default')?.doc ?? '') : '') // Purpose: its page, else its own comment, else — for a file of one part — // the file's. A pass-on says whose it is. const page = pass ? null : join(dirname(p.file), `${p.name}.mdx`).split(sep).join('/') const pageText = page && existsSync(join(app, page)) ? pageOpening(readFileSync(join(app, page), 'utf8')) : '' let purpose = '' let purposeFrom: RegistryEntry['purposeFrom'] = 'comment' if (pass) { const as = pass.name === p.name ? '' : ` as ${p.name}` purpose = isPackageImport(pass.specifier) ? `The package's ${pass.name}, handed on${as} so the app can import it from ${importPathOf(p.file)}.` : `${pass.name} from ${pass.specifier}, handed on${as} so the app can import it from ${importPathOf(p.file)}.` purposeFrom = 'package' } else if (firstSentence(pageText)) { purpose = firstSentence(pageText) purposeFrom = 'page' } else if (firstSentence(doc)) { purpose = firstSentence(doc) } else if (onePart && firstSentence(header)) { purpose = firstSentence(header) purposeFrom = 'file' } if (!purpose) { problems.push(`${p.name} in ${p.file} has no one-line description. Write one as a /** … */ comment directly above it${onePart ? ', or at the top of the file' : ''}.`) continue } // Its kind: written beside it, or read from the evidence. const writtenLine = WRITTEN.exec(doc) ?? (onePart ? WRITTEN.exec(header) : null) const ties = pass ? [] : tiesOf(p.file) let cls: EntryClass let reason: string let written = false if (pass) { cls = 're-export' reason = `Its file only hands on ${isPackageImport(pass.specifier) ? `the package's ${pass.name}` : `${pass.name} from ${pass.specifier}`}.` if (writtenLine) problems.push(`${p.name} in ${p.file} is a pass-on, so its kind is read from the file: remove "@registry ${writtenLine[1]}"`) } else if (writtenLine) { const said = writtenLine[1] as EntryClass if (!WRITABLE.includes(said)) { problems.push(`${p.name} in ${p.file} says "@registry ${writtenLine[1]}": the kinds a person may write are ${WRITABLE.join(', ')}`) continue } cls = said reason = writtenLine[2].trim() written = true } else if (usedIn.length === 0) { cls = 'unused' reason = u.stories.size ? 'No file of the app uses it; only its stories do.' : u.tests.size ? 'No file of the app uses it; only its tests do.' : 'Nothing uses it.' } else if (usedIn.length === 1) { cls = 'one-off' reason = `Used in one place: ${usedIn[0]}.` } else if (ties.length) { cls = 'reusable' reason = `Used in ${usedIn.length} places, and tied to the app by ${ties[0]}${ties.length > 1 ? ` and ${ties.length - 1} more` : ''}.` } else { cls = 'promote-candidate' reason = `Used in ${usedIn.length} places, and everything it uses is already in the package.` } const namesake = namesakes.has(p.name) && !(pass && isPackageImport(pass.specifier) && pass.name === p.name) ? p.name : null if (namesake && !(NAMESAKE.test(doc) || (onePart && NAMESAKE.test(header)))) { problems.push(`${p.name} in ${p.file} has the name of the package's ${namesake}. Use the package's, or write why this one is kept: "@registry namesake: " in its /** … */ comment.`) } const shape: Resolved = declared ? module!.resolve(declared.propsType) : { props: [], variants: [] } const facts_: AppFacts = { class: cls, reason, written, handsOn: pass ? (isPackageImport(pass.specifier) ? pass.name : `${pass.name} from ${pass.specifier}`) : null, usedIn, tiedTo: ties, packageNamesake: namesake, defaultExport: p.defaultExport, } const found = pass ? { docsId: null, storyId: null } : storyOf(p) // A part with a page of its own and no story file links to its page, by the // page's own title (Katerina's ruling B10, 25 September): Peek's EditedMarker.mdx // shows the part in other parts' stories, and nothing linked it. const titled = !found.docsId && pageText ? / a.name.localeCompare(b.name) || a.sourceFile.localeCompare(b.sourceFile)) const withParts = new Set(found.map((p) => p.file)) const filesWithoutParts: FileWithoutPart[] = targets .filter((file) => !withParts.has(file)) .map((file) => { const f = facts.get(file)! const names = [...f.exports.map((e) => (e.isDefault ? (e.local ?? 'a default') : e.name)), ...f.reexports.map((r) => r.name)] return { file, reason: names.length ? `It exports no part, only ${names.join(', ')}.` : 'It exports nothing.' } }) return { schemaVersion: SCHEMA_VERSION, builtFrom: { kind: 'app', repo: repoName, package: manifest.name ?? repoName, packageVersion: manifest.version ?? '0.0.0', exports: found.length, typeExports: 0, files: targets.length, }, storybook: { docsPath: '/?path=/docs/{docsId}', storyPath: '/?path=/story/{storyId}', devUrl: `http://localhost:${port}`, }, entries, excluded: [], filesWithoutParts, } }