check-ui-package.mjs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. #!/usr/bin/env node
  2. /**
  3. * Finish and verify the `@colbymchenry/codegraph-ui` build (task CG-61).
  4. *
  5. * `svelte-package` compiles the whole of `ui/src`, which is the right input —
  6. * the components a host imports and the ones `codegraph ui` renders are the
  7. * same files, and splitting them into two trees is how the two screens start
  8. * to drift. But it means the emitted `dist/` also carries the standalone app's
  9. * shell, and one of those files is a hazard rather than dead weight:
  10. * `lib/router.svelte.js` attaches `hashchange`/`popstate` listeners at module
  11. * scope. A host must never inherit a hash router just by rendering a Symbol
  12. * view. So this script does three jobs, in order:
  13. *
  14. * 1. PRUNE the app-only files from the package.
  15. * 2. RESOLVE the extensionless relative specifiers `svelte-package` leaves
  16. * behind, so the package works under Node's own ESM resolution and under
  17. * a consumer on `moduleResolution: node16`, not only inside a bundler.
  18. * 3. ASSERT the result: the entry, the theme, every path in `exports`, the
  19. * five named components, and — the one that matters most — that nothing
  20. * outside `lib/adapter.js` talks to the network. The whole point of the
  21. * package is that a host's own adapter is the only way data arrives; a
  22. * stray `fetch` anywhere else is a screen that ignores it.
  23. *
  24. * Run by `npm run build:lib -w ui`. Exits non-zero on any failure.
  25. */
  26. import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
  27. import { dirname, join, relative, resolve } from 'node:path';
  28. import { fileURLToPath } from 'node:url';
  29. const UI = fileURLToPath(new URL('../ui', import.meta.url));
  30. const DIST = join(UI, 'dist');
  31. /**
  32. * The standalone viewer's shell — everything that is only reachable from
  33. * `main.ts`. Listed by hand rather than derived, because getting it wrong in
  34. * the derived direction (pruning something a component needs) is silent until
  35. * a host imports it.
  36. */
  37. const APP_ONLY = [
  38. 'main.js',
  39. 'main.d.ts',
  40. 'App.svelte',
  41. 'App.svelte.d.ts',
  42. 'app.css',
  43. 'components/TopBar.svelte',
  44. 'components/TopBar.svelte.d.ts',
  45. 'lib/router.svelte.js',
  46. 'lib/router.svelte.d.ts',
  47. ];
  48. /** Extensions that already resolve; anything else is rewritten to `<spec>.js`. */
  49. const RESOLVES = ['.js', '.mjs', '.cjs', '.json', '.css', '.svg', '.png'];
  50. const fail = (message) => {
  51. console.error(`[check-ui-package] ${message}`);
  52. process.exitCode = 1;
  53. };
  54. if (!existsSync(DIST)) {
  55. fail(`no ${relative(UI, DIST)} — run \`npm run build:lib -w ui\``);
  56. process.exit(1);
  57. }
  58. /* ------------------------------------------------------------------ 1. prune */
  59. for (const entry of APP_ONLY) {
  60. const path = join(DIST, entry);
  61. if (existsSync(path)) rmSync(path, { recursive: true });
  62. }
  63. /* ------------------------------------------------------------------ walk it */
  64. function* files(dir) {
  65. for (const name of readdirSync(dir)) {
  66. const path = join(dir, name);
  67. if (statSync(path).isDirectory()) yield* files(path);
  68. else yield path;
  69. }
  70. }
  71. const all = [...files(DIST)];
  72. /* ---------------------------------------------------------------- 2. resolve */
  73. /**
  74. * `from './lib/adapter'` -> `from './lib/adapter.js'`, and
  75. * `from './lib/trail.svelte'` -> `from './lib/trail.svelte.js'` (the emitted
  76. * file for a `.svelte.ts` rune module).
  77. *
  78. * Driven by the filesystem rather than by the extension alone: `.svelte` is a
  79. * real file for a component and a compiled `.js` for a rune module, and only
  80. * looking is right for both.
  81. */
  82. function resolveSpecifiers(source, fromFile) {
  83. return source.replace(
  84. /(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.[^'"]*)\2/g,
  85. (match, head, quote, spec) => {
  86. if (RESOLVES.some((ext) => spec.endsWith(ext))) return match;
  87. const target = resolve(dirname(fromFile), spec);
  88. if (existsSync(target) && statSync(target).isFile()) return match;
  89. if (!existsSync(`${target}.js`)) return match;
  90. return `${head}${quote}${spec}.js${quote}`;
  91. }
  92. );
  93. }
  94. let rewritten = 0;
  95. for (const path of all) {
  96. if (!/\.(js|d\.ts|svelte)$/.test(path)) continue;
  97. const before = readFileSync(path, 'utf8');
  98. const after = resolveSpecifiers(before, path);
  99. if (after !== before) {
  100. writeFileSync(path, after);
  101. rewritten += 1;
  102. }
  103. }
  104. /* ----------------------------------------------------------------- 3. assert */
  105. const manifest = JSON.parse(readFileSync(join(UI, 'package.json'), 'utf8'));
  106. // Every path the exports map promises has to be there. A missing one is a
  107. // package that installs cleanly and then fails at the consumer's first import.
  108. for (const [name, entry] of Object.entries(manifest.exports ?? {})) {
  109. const targets = typeof entry === 'string' ? [entry] : Object.values(entry);
  110. for (const target of targets) {
  111. if (!target.startsWith('./')) continue;
  112. if (!existsSync(join(UI, target))) fail(`exports["${name}"] -> ${target} is missing`);
  113. }
  114. }
  115. // The exported screens, plus the two seams they are useless
  116. // without. Checked in the emitted JS, so a rename in index.ts that misses a
  117. // component fails here rather than in the Pro app.
  118. const entry = existsSync(join(DIST, 'index.js'))
  119. ? readFileSync(join(DIST, 'index.js'), 'utf8')
  120. : '';
  121. for (const name of [
  122. 'SymbolView',
  123. 'TypeHierarchy',
  124. 'FlowStrip',
  125. 'ArchitectureMap',
  126. 'DeadCodeView',
  127. 'TrailBar',
  128. 'SavedTrails',
  129. 'SearchPalette',
  130. 'CodegraphUi',
  131. 'setGraphAdapter',
  132. 'createHttpAdapter',
  133. 'setNavigationDriver',
  134. ]) {
  135. if (!new RegExp(`\\b${name}\\b`).test(entry)) fail(`dist/index.js does not export ${name}`);
  136. }
  137. // Nothing the app dragged in survives. A component still importing one of the
  138. // pruned modules would resolve to nothing in a host.
  139. for (const path of all) {
  140. if (!existsSync(path)) continue;
  141. const text = readFileSync(path, 'utf8');
  142. for (const pruned of ['router.svelte', 'TopBar.svelte', 'app.css']) {
  143. const importing = new RegExp(`(from|import\\()\\s*['"][^'"]*${pruned}`);
  144. if (importing.test(text)) {
  145. fail(`${relative(DIST, path)} still imports ${pruned}, which is app-only`);
  146. }
  147. }
  148. }
  149. // The data seam. `lib/adapter.js` is the ONE place that may reach the network;
  150. // anywhere else means a screen that ignores the host's adapter.
  151. for (const path of all) {
  152. if (!existsSync(path) || !path.endsWith('.js')) continue;
  153. if (path.endsWith(join('lib', 'adapter.js'))) continue;
  154. const text = readFileSync(path, 'utf8')
  155. // Comments talk about `fetch` and `EventSource` on purpose; only code counts.
  156. .replace(/\/\*[\s\S]*?\*\//g, '')
  157. .replace(/(^|\s)\/\/[^\n]*/g, '');
  158. if (/\bnew EventSource\b|\bfetch\s*\(/.test(text)) {
  159. fail(`${relative(DIST, path)} reaches the network directly — it must go through the adapter`);
  160. }
  161. }
  162. if (process.exitCode) {
  163. console.error('[check-ui-package] FAILED');
  164. process.exit(1);
  165. }
  166. const count = [...files(DIST)].length;
  167. console.log(
  168. `[check-ui-package] ok — ${count} files, ${rewritten} rewritten, ` +
  169. `${APP_ONLY.length} app-only pruned (v${manifest.version})`
  170. );