/** * Every named import in a browser app resolves to something its target * actually exports. * * The `.jsx` files are in neither typecheck project, so tsc never reads them, * and biome's noUndeclaredVariables sees a name that was imported as declared * whether or not the module has it. A rename that misses one call site * therefore passes the whole gate and fails in the browser on first paint, * which is what this closes. * * Reading the source rather than importing it: a component module pulls in the * DOM, `import.meta.env` and a React runtime, none of which a node test has. */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); /** The apps with a `#/` alias, which is where the exposure is. */ const APPS = ['git-ui', 'spaces-ui', 'account-ui']; /** @param {string} dir @returns {string[]} */ function sourcesUnder(dir) { /** @type {string[]} */ const found = []; for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) found.push(...sourcesUnder(path)); else if (/\.jsx?$/.test(name)) found.push(path); } return found; } /** * The names one module exports, from its source. Covers the three forms this * codebase writes: a declaration, a list, and a list re-exported from * elsewhere, which is followed to the module that declares them. * @param {string} path * @param {Set} [seen] - modules already followed, so a cycle ends * @returns {Set|null} null where the file is not there */ function exportsOf(path, seen = new Set()) { if (seen.has(path)) return new Set(); seen.add(path); /** @type {string} */ let source; try { source = readFileSync(path, 'utf8'); } catch { return null; } /** @type {Set} */ const names = new Set(); const declared = /^export\s+(?:async\s+)?(?:function|const|let|class)\s+(\w+)/gm; for (const match of source.matchAll(declared)) names.add(match[1]); const listed = /^export\s*\{([^}]*)\}\s*(?:from\s*'([^']+)')?/gm; for (const match of source.matchAll(listed)) { for (const part of match[1].split(',')) { const name = part .trim() .split(/\s+as\s+/) .pop() ?.trim(); if (name) names.add(name); } // A re-export of a whole module's names cannot be read from the list, so // the module it names is read too. if (match[2]?.startsWith('.')) { const target = resolve(dirname(path), match[2]); for (const name of exportsOf(target, seen) ?? []) names.add(name); } } return names; } /** * Every `import { … } from '#/…'` in one file. * @param {string} source * @returns {Array<{names: string[], from: string}>} */ function importsIn(source) { return [ ...source.matchAll(/import\s*\{([^}]*)\}\s*from\s*'(#\/[^']+)'/g), ].map((match) => ({ names: match[1] .split(',') .map((/** @type {string} */ part) => part .trim() .split(/\s+as\s+/)[0] .trim(), ) .filter(Boolean), from: match[2], })); } describe.each(APPS)('%s', (app) => { const src = join(root, 'packages', app, 'src'); it('imports only names its own modules export', () => { /** @type {string[]} */ const broken = []; for (const file of sourcesUnder(src)) { const source = readFileSync(file, 'utf8'); for (const { names, from } of importsIn(source)) { const target = join(src, from.slice(2)); const exported = exportsOf(target); const where = file.slice(root.length + 1); if (!exported) { broken.push(`${where}: ${from} is not a file`); continue; } for (const name of names) { if (!exported.has(name)) { broken.push(`${where}: ${from} exports no ${name}`); } } } } expect(broken).toEqual([]); }); }); /** * No module a browser app imports reaches a node-only one. * * A bundler drops what nothing calls, so a production build of a package * whose graph touches `node:http` still comes out clean. A dev server does * not: it evaluates the module, and the page dies on the import. Reading the * graph rather than the build is what tells the two apart. */ describe('browser packages and node-only modules', () => { /** Where each bare specifier the apps import resolves to. */ const ENTRYPOINTS = { '@pdsjs/git/authors': 'packages/git/src/authors.js', '@pdsjs/git/browser': 'packages/git/src/browser.js', '@pdsjs/git/client': 'packages/git/src/client.js', '@pdsjs/git/rules': 'packages/git/src/rules.js', '@pdsjs/git-ci/spec': 'packages/git-ci/src/spec.js', }; /** * Every relative module one entrypoint reaches, and every `node:` import * among them. * @param {string} start - path from the repository root * @returns {string[]} each reach, as ` imports ` */ const nodeImportsUnder = (start) => { const seen = new Set(); /** @type {string[]} */ const found = []; /** @param {string} file */ const walk = (file) => { if (seen.has(file)) return; seen.add(file); /** @type {string} */ let source; try { source = readFileSync(file, 'utf8'); } catch { return; } for (const [, specifier] of source.matchAll( /^(?:import|export)[^;]*?from\s+'([^']+)'/gm, )) { if (specifier.startsWith('node:')) { found.push(`${file.slice(root.length + 1)} imports ${specifier}`); } else if (specifier.startsWith('.')) { walk(resolve(dirname(file), specifier)); } } }; walk(resolve(root, start)); return found; }; for (const [specifier, path] of Object.entries(ENTRYPOINTS)) { it(`${specifier} reaches nothing that needs node`, () => { expect(nodeImportsUnder(path)).toEqual([]); }); } });