#!/usr/bin/env node /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /** * Enumerates every product UI surface file and emits a file-level Astryx-fit * inventory (markdown + machine-readable path list). * * Both the known Astryx component set and the `@maka/ui` re-export map are * derived from source at generation time (never hand-maintained), and the * derivation is fail-closed: any unresolved barrel target, a version that does * not match the pinned dependency, or a suspiciously small parse aborts the run * rather than emitting a partial inventory (see #3868). * * Run: node scripts/generate-astryx-surface-inventory.mjs * Writes: docs/astryx-surface-file-inventory.md * docs/astryx-surface-file-inventory.paths */ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const root = join(fileURLToPath(new URL('..', import.meta.url))); const TREES = [join(root, 'apps/desktop/src/renderer'), join(root, 'packages/ui/src')]; const EXCLUDE_DIR = new Set([ '__tests__', 'stories', 'node_modules', 'dist', 'astryx-theme', 'locales', 'computer-use-overlay', // engine + palette, not product chrome inventory ]); /** Basename patterns excluded with explicit reason (coverage gate uses same list). */ export const EXCLUDE_BASENAME = [ { re: /-copy\.tsx?$/, reason: 'locale/copy helper, not a surface' }, { re: /\.test\.tsx?$/, reason: 'unit test' }, { re: /\.spec\.tsx?$/, reason: 'spec' }, { re: /\.d\.ts$/, reason: 'types only' }, { re: /^index\.tsx?$/, reason: 'barrel re-export' }, { re: /^main\.tsx$/, reason: 'bundle entry, not a surface' }, { re: /-model\.ts$/, reason: 'view-model/logic without UI' }, { re: /-filter\.ts$/, reason: 'pure filter logic' }, { re: /-lifecycle\.ts$/, reason: 'lifecycle helper' }, { re: /-helpers?\.ts$/, reason: 'helpers without UI' }, { re: /^use-.*\.ts$/, reason: 'hook implementation (UI is in consumer tsx)' }, ]; const NAMED_COMPONENT_RE = /^[A-Z][A-Za-z0-9]*$/; /** * Sanity floor for the derived Astryx set plus a few components that must be * present. If the declaration parse regresses to a partial result, these * assertions turn it into a hard failure instead of a quietly shrunk inventory. */ const MIN_ASTRYX_COMPONENTS = 150; const KEY_ASTRYX_COMPONENTS = [ 'Button', 'TextInput', 'Selector', 'Text', 'Card', 'EmptyState', 'Dialog', 'Timestamp', 'ChatMessage', 'Thumbnail', ]; function stripComments(src) { return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1'); } /** Add PascalCase value exports from one `.d.ts` block to `into`. */ function collectDeclaredComponents(block, into) { // export { A, B as C, type D } → A, C (skip type-only, *Props, *Context) for (const m of block.matchAll(/export\s*\{([^}]*)\}/g)) { for (const raw of m[1].split(',')) { const part = raw.trim(); if (!part || /^type\s/.test(part)) continue; const asMatch = part.match(/\bas\s+([A-Za-z0-9_]+)$/); const name = asMatch ? asMatch[1] : part.split(/\s+/)[0]; if (NAMED_COMPONENT_RE.test(name) && !name.endsWith('Props') && !name.endsWith('Context')) { into.add(name); } } } // export declare const|function|class Name for (const m of block.matchAll( /export\s+declare\s+(?:const|function|class)\s+([A-Za-z0-9_]+)/g, )) { if (NAMED_COMPONENT_RE.test(m[1])) into.add(m[1]); } } /** The exact `@astryxdesign/core` version pinned in the repo's root manifest. */ function pinnedAstryxVersion(repoRoot) { const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; const pin = deps['@astryxdesign/core']; return pin ? pin.replace(/^[\^~=]/, '') : null; } let astryxComponentsCache = null; /** * Read one declaration file, fold its PascalCase component exports into `set`, * then recurse through every `export * from './rel'` it declares. Barrel targets * resolve relative to the file's own directory, so a nested * `dist/Group/index.d.ts` → `export * from './Sub'` reads `dist/Group/Sub/…` — * the full barrel graph, not just the first layer. Fail-closed: an unreadable * file or an unresolved relative target throws rather than silently shrinking * the set. `seen` guards import cycles and repeated reads. */ function collectDistBarrel(file, set, seen) { if (seen.has(file)) return; seen.add(file); let src; try { src = readFileSync(file, 'utf8'); } catch { throw new Error( `cannot derive Astryx component set: ${relative(root, file)} is unreadable — reinstall @astryxdesign/core`, ); } collectDeclaredComponents(src, set); const dir = dirname(file); for (const m of src.matchAll(/export\s*\*\s*from\s*['"](\.[^'"]*)['"]/g)) { const spec = m[1]; // any relative barrel: './Dir', '../Sibling', … const base = resolve(dir, spec); const dirIndex = join(base, 'index.d.ts'); const flat = `${base}.d.ts`; if (existsSync(dirIndex)) collectDistBarrel(dirIndex, set, seen); else if (existsSync(flat)) collectDistBarrel(flat, set, seen); else throw new Error( `cannot derive Astryx component set: unresolved \`export * from '${spec}'\` in ${relative(root, file)} — reinstall @astryxdesign/core`, ); } } /** * Parse an `@astryxdesign/core` `dist` directory into its set of PascalCase * component exports: `dist/index.d.ts` plus every per-dir `index.d.ts` (or flat * `Dir.d.ts`) reachable through the `export * from './Dir'` barrel graph, * traversed recursively so a future multi-level barrel cannot silently drop * components. Fail-closed — an unresolved barrel target throws rather than * quietly shrinking the set. Pure (no memo, no version check) so it is * unit-testable against fixture declaration dirs. */ export function parseAstryxDistComponents(distDir) { const set = new Set(); collectDistBarrel(join(distDir, 'index.d.ts'), set, new Set()); return set; } /** * Reject a suspiciously small or key-component-missing parse. Turns a partial * declaration read into a hard failure instead of a quietly shrunk inventory. */ export function assertAstryxComponentSet(set, version) { if (set.size < MIN_ASTRYX_COMPONENTS) { throw new Error( `Astryx component parse looks incomplete: derived ${set.size} components (< ${MIN_ASTRYX_COMPONENTS}) from @astryxdesign/core@${version}`, ); } const missing = KEY_ASTRYX_COMPONENTS.filter((name) => !set.has(name)); if (missing.length > 0) { throw new Error( `Astryx component parse is missing expected components: ${missing.join(', ')} — the declaration format may have changed`, ); } } /** * Resolve `@astryxdesign/core`, parse its shipped `.d.ts`, and return the * installed version alongside the sorted set of exported component names. We * parse the declarations rather than importing the ESM, because the runtime * has StyleX/CSS side effects a plain `node` import cannot evaluate. * * Fail-closed: an unresolved barrel target, a version that disagrees with the * pinned dependency, or a parse that yields fewer than `MIN_ASTRYX_COMPONENTS` * (or is missing a key component) throws rather than returning a partial set — * a silently incomplete set is exactly the drift #3868 removes. Memoized. */ export function loadAstryxComponents() { if (astryxComponentsCache) return astryxComponentsCache; const require = createRequire(import.meta.url); let distDir; try { distDir = dirname(require.resolve('@astryxdesign/core')); } catch { throw new Error( 'cannot derive Astryx component set: @astryxdesign/core did not resolve — run `npm install` (deps are required to generate the inventory)', ); } const pkgPath = join(dirname(distDir), 'package.json'); const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version; const pinned = pinnedAstryxVersion(root); if (pinned && pinned !== version) { throw new Error( `Astryx version drift: resolved @astryxdesign/core@${version} but the root package.json pins ${pinned} — regenerate after \`npm ci\``, ); } const set = parseAstryxDistComponents(distDir); assertAstryxComponentSet(set, version); astryxComponentsCache = { version, components: new Set([...set].sort()) }; return astryxComponentsCache; } /** * Resolve a TypeScript source module referenced by a `.js` import specifier * (Node16 style) to its on-disk `.ts`/`.tsx` (or directory `index`). */ function resolveSourceModule(fromFile, spec) { const base = resolve(dirname(fromFile), spec.replace(/\.js$/, '')); for (const candidate of [ `${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx'), ]) { if (existsSync(candidate)) return candidate; } return null; } /** * Resolve the exported names of a source module to their origin: * { kind: 'astryx', canonical } — (transitively) re-exported from @astryxdesign/core * { kind: 'local', file } — defined in `file` * { kind: 'external' } — re-exported from a third-party package * Recurses through relative `export *` / `export { … } from './x'` chains and * throws on any unresolved relative target (fail-closed). `memo`/`seen` bound * work and guard import cycles. */ function collectModuleExports(file, components, memo, seen) { if (memo.has(file)) return memo.get(file); if (seen.has(file)) return new Map(); seen.add(file); const out = new Map(); const code = stripComments(readFileSync(file, 'utf8')); // Local bindings imported from Astryx, so a bare `export { X }` can be classified. const astryxLocalToOrig = new Map(); for (const m of code.matchAll( /import\s*\{([^}]+)\}\s*from\s*['"]@astryxdesign\/core[^'"]*['"]/g, )) { for (const raw of m[1].split(',')) { const part = raw.trim().replace(/^type\s+/, ''); if (!part) continue; const asMatch = part.match(/^(\w+)\s+as\s+(\w+)$/); const orig = asMatch ? asMatch[1] : part.split(/\s+/)[0]; const local = asMatch ? asMatch[2] : orig; if (local) astryxLocalToOrig.set(local, orig); } } // export * from '' for (const m of code.matchAll(/export\s*\*\s*from\s*['"]([^'"]+)['"]/g)) { const spec = m[1]; if (spec.startsWith('@astryxdesign/core')) { for (const canonical of components) out.set(canonical, { kind: 'astryx', canonical }); } else if (spec.startsWith('.')) { const target = resolveSourceModule(file, spec); if (!target) { throw new Error( `cannot resolve @maka/ui exports: unresolved \`export * from '${spec}'\` in ${relative(root, file)}`, ); } for (const [name, origin] of collectModuleExports(target, components, memo, seen)) { out.set(name, origin); } } // A non-relative `export *` (e.g. from 'react') re-exports unknowable names; // none is an Astryx twin or a local component, so it cannot shadow one. } // export { A, B as C, type D } [from ''] for (const m of code.matchAll(/export\s*\{([^}]*)\}\s*(?:from\s*['"]([^'"]+)['"])?/g)) { const spec = m[2]; let relExports = null; if (spec && spec.startsWith('.')) { const target = resolveSourceModule(file, spec); if (!target) { throw new Error( `cannot resolve @maka/ui exports: unresolved \`export { … } from '${spec}'\` in ${relative(root, file)}`, ); } relExports = collectModuleExports(target, components, memo, seen); } for (const raw of m[1].split(',')) { const part = raw.trim(); if (!part || /^type\s/.test(part)) continue; const asMatch = part.match(/^([A-Za-z0-9_]+)\s+as\s+([A-Za-z0-9_]+)$/); const orig = asMatch ? asMatch[1] : part.split(/\s+/)[0]; const pub = asMatch ? asMatch[2] : orig; if (spec && spec.startsWith('@astryxdesign/core')) { out.set(pub, { kind: 'astryx', canonical: orig }); } else if (relExports) { out.set(pub, relExports.get(orig) || { kind: 'local', file }); } else if (spec) { out.set(pub, { kind: 'external' }); } else { out.set( pub, astryxLocalToOrig.has(orig) ? { kind: 'astryx', canonical: astryxLocalToOrig.get(orig) } : { kind: 'local', file }, ); } } } // export const/function/class Name (a local definition) for (const m of code.matchAll( /export\s+(?:async\s+)?(?:const|function|class)\s+([A-Za-z0-9_]+)/g, )) { out.set(m[1], { kind: 'local', file }); } memo.set(file, out); return out; } const makaUiBarrelCache = new Map(); /** * Resolve the public `@maka/ui` surface (`packages/ui/src/index.ts`) into: * - `reexports`: Map — every name the barrel * re-exports (transitively) from @astryxdesign/core. Replaces a hand-written * list; the "Astryx used" column and the shadow check share this one fact. * - `shadowsByFile`: Map> — public exports whose name * matches a shipped Astryx component but which are defined locally (not a * re-export). This is the only "reimplementation" signal we assert on: a * name collision is not proof of a semantic re-implementation, but a public * API that shadows an Astryx component is worth surfacing in review. * Memoized per repo root; fail-closed via `collectModuleExports`. */ export function loadMakaUiBarrel(repoRoot = root) { if (makaUiBarrelCache.has(repoRoot)) return makaUiBarrelCache.get(repoRoot); const { components } = loadAstryxComponents(); const indexFile = join(repoRoot, 'packages/ui/src/index.ts'); if (!existsSync(indexFile)) { throw new Error(`cannot resolve @maka/ui exports: ${relative(repoRoot, indexFile)} not found`); } const exportsMap = collectModuleExports(indexFile, components, new Map(), new Set()); const reexports = new Map(); const shadowsByFile = new Map(); for (const [name, origin] of exportsMap) { if (origin.kind === 'astryx') { reexports.set(name, origin.canonical); } else if (origin.kind === 'local' && origin.file && components.has(name)) { const rel = relative(repoRoot, origin.file).replaceAll('\\', '/'); if (!shadowsByFile.has(rel)) shadowsByFile.set(rel, new Set()); shadowsByFile.get(rel).add(name); } } const result = { reexports, shadowsByFile }; makaUiBarrelCache.set(repoRoot, result); return result; } const RAW_BUTTON_RE = /<\s*button\b/; const RAW_INPUT_RE = /<\s*input\b/; const RAW_SELECT_RE = /<\s*select\b/; const RAW_TEXTAREA_RE = /<\s*textarea\b/; const HEX_RE = /#[0-9a-fA-F]{3,8}\b/; const OFF_HEIGHT_RE = /(?:min-)?height:\s*(\d+)px/g; const ALLOWED_H = new Set([28, 32, 36]); function walk(dir, out = []) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } for (const ent of entries) { if (EXCLUDE_DIR.has(ent.name)) continue; const full = join(dir, ent.name); if (ent.isDirectory()) walk(full, out); else if (ent.isFile()) out.push(full); } return out; } export function listProductSurfaceFiles(repoRoot = root) { const files = []; const excluded = []; for (const tree of [ join(repoRoot, 'apps/desktop/src/renderer'), join(repoRoot, 'packages/ui/src'), ]) { for (const full of walk(tree)) { const rel = relative(repoRoot, full).replaceAll('\\', '/'); const base = full.split(/[/\\]/).pop(); const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : ''; if (ext !== '.tsx' && ext !== '.css') continue; // packages/ui only inventory CSS at styles.css + non-test; desktop styles/** if (ext === '.css') { if (rel.includes('/styles/') || base === 'styles.css' || rel.includes('/styles.css')) { // ok } else if (!rel.includes('/styles/')) { // e.g. packages/ui/src/foo.css if any } } let skip = null; for (const rule of EXCLUDE_BASENAME) { if (rule.re.test(base)) { skip = rule.reason; break; } } if (skip) { excluded.push({ path: rel, reason: skip }); continue; } // Pure .ts hooks already excluded by basename; also skip non-surface tsx that are clearly non-UI if (ext === '.tsx') { // keep all remaining tsx under the trees } files.push(rel); } } files.sort(); excluded.sort((a, b) => a.path.localeCompare(b.path)); return { files, excluded }; } function roleFor(rel) { if (rel.includes('/settings/') && /-(page|modal)\.tsx$/.test(rel)) return 'settings-page'; if (rel.includes('/settings/')) return 'settings-module'; if (/mcp-page|skills-panel|scheduled-task|daily-review/.test(rel)) return 'module-hub'; if (/dialog|modal|command-palette|keyboard-help|onboarding/.test(rel)) return 'dialog-overlay'; if ( /panel|workbar|inspector|terminal|browser|artifact|composer|chat-|app-shell|titlebar|sidebar|session-/.test( rel, ) ) { return 'shell-chrome-or-panel'; } if (rel.includes('/primitives/')) return 'primitive'; if (rel.startsWith('packages/ui/')) return 'ui-composition'; if (rel.endsWith('.css')) return 'styles'; return 'other'; } /** * Collect Astryx component names used in a file from real imports + JSX only. * - import { Button as UiButton } from '@astryxdesign/core' → Button (canonical) * - import { Badge } from '@maka/ui' when Badge is a derived Astryx re-export * -