~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
189 lines · 6.0 KB Raw History
/**
 * 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<string>} [seen] - modules already followed, so a cycle ends
 * @returns {Set<string>|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<string>} */
  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 `<file> imports <specifier>`
   */
  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([]);
    });
  }
});