check-ui-build.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. #!/usr/bin/env node
  2. /**
  3. * Assert that the browser viewer actually built.
  4. *
  5. * `codegraph ui` serves dist/viewer/ as static files. If that tree is missing
  6. * or half-written, the CLI still starts and the browser gets a 404 — a failure
  7. * that would otherwise surface after the release is published. So the build
  8. * fails here instead: index.html must exist, be non-trivial, and every local
  9. * asset it references must be on disk next to it.
  10. *
  11. * It also re-asserts that the compiled engine is still there. The viewer build
  12. * empties its own output directory, and `dist/ui/` — the obvious name — is
  13. * where tsc puts the TERMINAL ui, so a mis-pointed outDir silently deletes
  14. * modules the CLI requires at startup.
  15. *
  16. * The tree-sitter grammars in dist/extraction/wasm/ are checked the same way
  17. * and for the same reason. They are copied by `npm run copy-assets`, they are
  18. * what both indexing and the viewer's syntax classification parse with, and
  19. * their absence is survivable at runtime — source is served unhighlighted —
  20. * which is exactly why it has to fail here: nothing downstream would complain.
  21. *
  22. * Usage: node scripts/check-ui-build.mjs [--root <dir>]
  23. * --root directory holding dist/ (default: the repo root). The release
  24. * bundler points this at its staging dir to verify the copy.
  25. */
  26. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
  27. import { dirname, join, resolve, sep } from 'node:path';
  28. import { fileURLToPath } from 'node:url';
  29. const argv = process.argv.slice(2);
  30. const rootFlag = argv.indexOf('--root');
  31. const staged = rootFlag >= 0 && Boolean(argv[rootFlag + 1]);
  32. const root = staged
  33. ? resolve(argv[rootFlag + 1])
  34. : resolve(dirname(fileURLToPath(import.meta.url)), '..');
  35. const viewerDir = join(root, 'dist', 'viewer');
  36. const indexHtml = join(viewerDir, 'index.html');
  37. function fail(message, hint) {
  38. console.error(`[check-ui-build] ${message}`);
  39. if (hint) console.error(`[check-ui-build] ${hint}`);
  40. process.exit(1);
  41. }
  42. if (!existsSync(indexHtml)) {
  43. fail(
  44. `missing ${indexHtml}`,
  45. staged
  46. ? 'this bundle predates the UI or was assembled from a stale archive — rebuild it with scripts/build-bundle.sh'
  47. : 'the UI workspace did not build — run `npm run build:ui` (or `npm ci` if ui/ has no node_modules)'
  48. );
  49. }
  50. const html = readFileSync(indexHtml, 'utf8');
  51. if (html.length < 200 || !/<div id="app">/.test(html)) {
  52. fail(`${indexHtml} does not look like the built viewer (${html.length} bytes)`);
  53. }
  54. // Every local src=/href= in the document must resolve inside dist/ui. This is
  55. // what catches a partial write: index.html naming a hashed bundle that the
  56. // build never emitted.
  57. const referenced = [...html.matchAll(/\s(?:src|href)="([^"]+)"/g)].map((m) => m[1]);
  58. const local = referenced.filter(
  59. (url) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url) && !url.startsWith('#')
  60. );
  61. const missing = [];
  62. let assets = 0;
  63. for (const url of local) {
  64. const rel = url.replace(/^\.\//, '').replace(/[?#].*$/, '');
  65. if (!rel) continue;
  66. const onDisk = join(viewerDir, ...rel.split('/'));
  67. if (!existsSync(onDisk) || !statSync(onDisk).isFile()) missing.push(rel);
  68. else assets += 1;
  69. }
  70. if (missing.length > 0) {
  71. fail(
  72. `index.html references ${missing.length} file(s) that are not in dist/viewer: ${missing.join(', ')}`,
  73. 'the UI build was interrupted or dist/viewer was copied incompletely'
  74. );
  75. }
  76. if (assets === 0) {
  77. fail('index.html references no bundled assets — the UI build produced no JS/CSS');
  78. }
  79. // The viewer build must never have eaten the tsc output next door.
  80. for (const compiled of [join('bin', 'codegraph.js'), 'index.js', join('ui', 'shimmer-progress.js')]) {
  81. if (!existsSync(join(root, 'dist', compiled))) {
  82. fail(
  83. `dist/${compiled.split(sep).join('/')} is missing — the compiled engine is incomplete`,
  84. "if this appeared with a UI change, check ui/vite.config.ts: build.outDir must stay dist/viewer, and emptyOutDir must never point at a directory tsc writes (dist/ui is the TERMINAL ui)"
  85. );
  86. }
  87. }
  88. // The vendored tree-sitter grammars (`npm run copy-assets`). The viewer reads
  89. // every file with the same grammar the engine indexed it with, so a missing
  90. // wasm is both an extraction gap and a silently unhighlighted screen.
  91. const wasmDir = join(root, 'dist', 'extraction', 'wasm');
  92. /**
  93. * The grammars the syntax classification is gated on — the eight languages
  94. * CG-57 measured parity against, plus the two the TS family needs. Every one is
  95. * vendored (see VENDORED_WASM_LANGS), so all of them must be in this directory
  96. * rather than resolved out of node_modules.
  97. */
  98. const GATE_GRAMMARS = [
  99. 'tree-sitter-typescript.wasm',
  100. 'tree-sitter-tsx.wasm',
  101. 'tree-sitter-javascript.wasm',
  102. 'tree-sitter-go.wasm',
  103. 'tree-sitter-python.wasm',
  104. 'tree-sitter-rust.wasm',
  105. 'tree-sitter-swift.wasm',
  106. 'tree-sitter-c_sharp.wasm',
  107. 'tree-sitter-ruby.wasm',
  108. 'tree-sitter-php.wasm',
  109. ];
  110. if (!existsSync(wasmDir)) {
  111. fail(
  112. `missing ${wasmDir}`,
  113. staged
  114. ? 'dist/extraction/wasm was not copied into the bundle — re-run scripts/build-bundle.sh'
  115. : 'run `npm run copy-assets` (it copies src/extraction/wasm/*.wasm into dist/)'
  116. );
  117. }
  118. // Against the source tree, the source directory IS the list — nothing to drift.
  119. // Inside a staged bundle there is no src/, so the gate list carries it.
  120. const expectedGrammars = new Set(GATE_GRAMMARS);
  121. const srcWasmDir = join(root, 'src', 'extraction', 'wasm');
  122. if (!staged && existsSync(srcWasmDir)) {
  123. for (const name of readdirSync(srcWasmDir)) {
  124. if (name.endsWith('.wasm')) expectedGrammars.add(name);
  125. }
  126. }
  127. const missingGrammars = [...expectedGrammars].filter(
  128. (name) => !existsSync(join(wasmDir, name))
  129. );
  130. if (missingGrammars.length > 0) {
  131. fail(
  132. `dist/extraction/wasm is missing ${missingGrammars.length} grammar(s): ${missingGrammars.join(', ')}`,
  133. 'the copy-assets step was interrupted or dist/extraction/wasm was copied incompletely'
  134. );
  135. }
  136. const grammarCount = readdirSync(wasmDir).filter((n) => n.endsWith('.wasm')).length;
  137. console.log(
  138. `[check-ui-build] dist/viewer ok (index.html + ${assets} referenced asset(s)); ` +
  139. `dist/extraction/wasm ok (${grammarCount} grammars); dist/ engine intact`
  140. );