| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- #!/usr/bin/env node
- /**
- * Finish and verify the `@colbymchenry/codegraph-ui` build (task CG-61).
- *
- * `svelte-package` compiles the whole of `ui/src`, which is the right input —
- * the components a host imports and the ones `codegraph ui` renders are the
- * same files, and splitting them into two trees is how the two screens start
- * to drift. But it means the emitted `dist/` also carries the standalone app's
- * shell, and one of those files is a hazard rather than dead weight:
- * `lib/router.svelte.js` attaches `hashchange`/`popstate` listeners at module
- * scope. A host must never inherit a hash router just by rendering a Symbol
- * view. So this script does three jobs, in order:
- *
- * 1. PRUNE the app-only files from the package.
- * 2. RESOLVE the extensionless relative specifiers `svelte-package` leaves
- * behind, so the package works under Node's own ESM resolution and under
- * a consumer on `moduleResolution: node16`, not only inside a bundler.
- * 3. ASSERT the result: the entry, the theme, every path in `exports`, the
- * five named components, and — the one that matters most — that nothing
- * outside `lib/adapter.js` talks to the network. The whole point of the
- * package is that a host's own adapter is the only way data arrives; a
- * stray `fetch` anywhere else is a screen that ignores it.
- *
- * Run by `npm run build:lib -w ui`. Exits non-zero on any failure.
- */
- import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
- import { dirname, join, relative, resolve } from 'node:path';
- import { fileURLToPath } from 'node:url';
- const UI = fileURLToPath(new URL('../ui', import.meta.url));
- const DIST = join(UI, 'dist');
- /**
- * The standalone viewer's shell — everything that is only reachable from
- * `main.ts`. Listed by hand rather than derived, because getting it wrong in
- * the derived direction (pruning something a component needs) is silent until
- * a host imports it.
- */
- const APP_ONLY = [
- 'main.js',
- 'main.d.ts',
- 'App.svelte',
- 'App.svelte.d.ts',
- 'app.css',
- 'components/TopBar.svelte',
- 'components/TopBar.svelte.d.ts',
- 'lib/router.svelte.js',
- 'lib/router.svelte.d.ts',
- ];
- /** Extensions that already resolve; anything else is rewritten to `<spec>.js`. */
- const RESOLVES = ['.js', '.mjs', '.cjs', '.json', '.css', '.svg', '.png'];
- const fail = (message) => {
- console.error(`[check-ui-package] ${message}`);
- process.exitCode = 1;
- };
- if (!existsSync(DIST)) {
- fail(`no ${relative(UI, DIST)} — run \`npm run build:lib -w ui\``);
- process.exit(1);
- }
- /* ------------------------------------------------------------------ 1. prune */
- for (const entry of APP_ONLY) {
- const path = join(DIST, entry);
- if (existsSync(path)) rmSync(path, { recursive: true });
- }
- /* ------------------------------------------------------------------ walk it */
- function* files(dir) {
- for (const name of readdirSync(dir)) {
- const path = join(dir, name);
- if (statSync(path).isDirectory()) yield* files(path);
- else yield path;
- }
- }
- const all = [...files(DIST)];
- /* ---------------------------------------------------------------- 2. resolve */
- /**
- * `from './lib/adapter'` -> `from './lib/adapter.js'`, and
- * `from './lib/trail.svelte'` -> `from './lib/trail.svelte.js'` (the emitted
- * file for a `.svelte.ts` rune module).
- *
- * Driven by the filesystem rather than by the extension alone: `.svelte` is a
- * real file for a component and a compiled `.js` for a rune module, and only
- * looking is right for both.
- */
- function resolveSpecifiers(source, fromFile) {
- return source.replace(
- /(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.[^'"]*)\2/g,
- (match, head, quote, spec) => {
- if (RESOLVES.some((ext) => spec.endsWith(ext))) return match;
- const target = resolve(dirname(fromFile), spec);
- if (existsSync(target) && statSync(target).isFile()) return match;
- if (!existsSync(`${target}.js`)) return match;
- return `${head}${quote}${spec}.js${quote}`;
- }
- );
- }
- let rewritten = 0;
- for (const path of all) {
- if (!/\.(js|d\.ts|svelte)$/.test(path)) continue;
- const before = readFileSync(path, 'utf8');
- const after = resolveSpecifiers(before, path);
- if (after !== before) {
- writeFileSync(path, after);
- rewritten += 1;
- }
- }
- /* ----------------------------------------------------------------- 3. assert */
- const manifest = JSON.parse(readFileSync(join(UI, 'package.json'), 'utf8'));
- // Every path the exports map promises has to be there. A missing one is a
- // package that installs cleanly and then fails at the consumer's first import.
- for (const [name, entry] of Object.entries(manifest.exports ?? {})) {
- const targets = typeof entry === 'string' ? [entry] : Object.values(entry);
- for (const target of targets) {
- if (!target.startsWith('./')) continue;
- if (!existsSync(join(UI, target))) fail(`exports["${name}"] -> ${target} is missing`);
- }
- }
- // The exported screens, plus the two seams they are useless
- // without. Checked in the emitted JS, so a rename in index.ts that misses a
- // component fails here rather than in the Pro app.
- const entry = existsSync(join(DIST, 'index.js'))
- ? readFileSync(join(DIST, 'index.js'), 'utf8')
- : '';
- for (const name of [
- 'SymbolView',
- 'TypeHierarchy',
- 'FlowStrip',
- 'ArchitectureMap',
- 'DeadCodeView',
- 'TrailBar',
- 'SavedTrails',
- 'SearchPalette',
- 'CodegraphUi',
- 'setGraphAdapter',
- 'createHttpAdapter',
- 'setNavigationDriver',
- ]) {
- if (!new RegExp(`\\b${name}\\b`).test(entry)) fail(`dist/index.js does not export ${name}`);
- }
- // Nothing the app dragged in survives. A component still importing one of the
- // pruned modules would resolve to nothing in a host.
- for (const path of all) {
- if (!existsSync(path)) continue;
- const text = readFileSync(path, 'utf8');
- for (const pruned of ['router.svelte', 'TopBar.svelte', 'app.css']) {
- const importing = new RegExp(`(from|import\\()\\s*['"][^'"]*${pruned}`);
- if (importing.test(text)) {
- fail(`${relative(DIST, path)} still imports ${pruned}, which is app-only`);
- }
- }
- }
- // The data seam. `lib/adapter.js` is the ONE place that may reach the network;
- // anywhere else means a screen that ignores the host's adapter.
- for (const path of all) {
- if (!existsSync(path) || !path.endsWith('.js')) continue;
- if (path.endsWith(join('lib', 'adapter.js'))) continue;
- const text = readFileSync(path, 'utf8')
- // Comments talk about `fetch` and `EventSource` on purpose; only code counts.
- .replace(/\/\*[\s\S]*?\*\//g, '')
- .replace(/(^|\s)\/\/[^\n]*/g, '');
- if (/\bnew EventSource\b|\bfetch\s*\(/.test(text)) {
- fail(`${relative(DIST, path)} reaches the network directly — it must go through the adapter`);
- }
- }
- if (process.exitCode) {
- console.error('[check-ui-package] FAILED');
- process.exit(1);
- }
- const count = [...files(DIST)].length;
- console.log(
- `[check-ui-package] ok — ${count} files, ${rewritten} rewritten, ` +
- `${APP_ONLY.length} app-only pruned (v${manifest.version})`
- );
|