Source: lib/compiler.js

import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { fileURLToPath } from 'url';
import loadConfig from './config.js';
const findProjectRoot = loadConfig.findProjectRoot;
import StyleProcessor from './compiler/StyleProcessor.js';
import ComponentParser from './compiler/ComponentParser.js';
import {
  analyzeBridgeFile,
  findBridgeImports,
  declaredMembers,
  extractSubscriptions,
  suggestName,
} from './compiler/BridgeParser.js';
import { logger } from './core/runtime/AvenxLogger.js';
import { performance } from 'perf_hooks';
import { AvenxErrorCodes } from './core/runtime/AvenxError.js';
import { BuildError } from './compiler/errors/index.js';
import { reportWarning } from './compiler/utils/warningReporter.js';
import { loadEnv, replaceEnvVariables } from './env.js';
import { assertValidOutputs, assertRuntimeCapabilities } from './compiler/bundle/validate.js';
import { NAMESPACE_GLOBAL, PUBLIC_GLOBALS } from './core/globals.js';
import { buildSidecar, sidecarFileName } from './compiler/sourceMapTrace.js';
import {
  bridgeModule,
  componentModule,
  devtoolsModule,
  interpreterModule,
  stringRendererModule,
  builtinComponentModule,
  entryModule,
  globalsModule,
  rewindConfigModule,
} from './compiler/modules.js';
import { bundle, ResolveError, BindingError, DynamicImportError, EmitError, ModuleParseError } from './bundler/index.js';
import { AppModel } from './compiler/atlas/AppModel.js';
import { addBridgeUnit, addRenderEdges } from './compiler/atlas/build.js';
import { addRoutesAndGuards } from './compiler/atlas/routes.js';
import { atlasFileName, serializeAtlas } from './compiler/atlas/emit.js';
import { reportAtlasDiagnostics } from './compiler/atlas/diagnostics.js';
import { reportRewindDiagnostics } from './compiler/rewind/diagnostics.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
 * Default size above which a build warns about the JavaScript it produced.
 *
 * Raised from 50 KB when the bundler replaced the concatenator, because 50 KB
 * was never satisfiable: the framework runtime alone is several times that, so
 * AVX_W01 fired on `avenx init` output and on every build after it. A warning
 * that always fires is a warning nobody reads, and it made the repository's own
 * bundle-size CI gate permanently red.
 *
 * The number is a ceiling for the whole bundle -- runtime included -- chosen to
 * sit above what an application of a few dozen components costs and to fire
 * when something large joins the graph. `bundleSizeWarningKb` in
 * avenx.config.json overrides it, because the honest number depends on what the
 * application is.
 * @type {number}
 */
const BUNDLE_SIZE_WARNING_THRESHOLD_KB = 600;

/**
 * Built-in components that are only linked when a template references them.
 *
 * `AvenxApp` used to import every built-in directly, which put each of them in
 * every bundle regardless of use. The registry in
 * `lib/core/runtime/builtins.js` is filled by these modules instead, and this
 * map is what decides which of them the entry graph reaches.
 * @type {Array<string[]>}
 */
const BUILTIN_COMPONENT_MODULES = [['VirtualList', 'avenx-core/runtime/virtual-list']];

/**
 * Resolves the build mode from configuration and environment.
 *
 * Production is the default so that a plain `avenx build` — what a deploy
 * script runs — produces optimised output. Development has to be asked for,
 * by `avenx build --dev`, by `mode`/`dev` in avenx.config.json, or by
 * NODE_ENV.
 * @param {object} config - The resolved compiler configuration.
 * @returns {'production'|'development'} The active mode.
 */
function resolveMode(config) {
  if (config.mode === 'development' || config.mode === 'production') {
    return config.mode;
  }
  if (config.dev === true) {
    return 'development';
  }
  if (process.env.NODE_ENV === 'development') {
    return 'development';
  }
  return 'production';
}

/**
 * AvenxCompiler is the main orchestrator for the Avenx-JS build process.
 * It coordinates the parsing of components, processing of styles, and the
 * final bundling of the application.
 */
class AvenxCompiler {
  /**
   * Creates an instance of AvenxCompiler and initializes its sub-processors.
   * @param {object} [options] - Optional custom settings to override config defaults.
   */
  constructor(options = {}) {
    /**
     * The root directory of the project.
     * @type {string}
     */
    this.rootDir = options.rootDir || findProjectRoot(process.cwd());
    loadEnv(this.rootDir);

    // Expose properties prefixed with AVX_PUBLIC_ to the compiler
    this.publicEnv = {};
    for (const key of Object.keys(process.env)) {
      if (key.startsWith('AVX_PUBLIC_')) {
        this.publicEnv[key] = process.env[key];
      }
    }

    const config = { ...loadConfig(this.rootDir), ...options };

    /**
     * The output bundle name without file extension.
     * Defaults to "bundle" when outputName is not configured.
     * @type {string}
     */
    this.outputName = config.outputName || 'bundle';

    // Configure logger for build-time compiler
    logger.configure({
      level: (config.logging && config.logging.level) || 'info',
      silent:
        (config.logging &&
          (config.logging.silent || config.logging.level === 'silent' || config.logging.level === 'off')) ||
        false,
      // CLI output doesn't need prefixes for generic info logs. The CLI injects its
      // own formatter (see bin/colors.js) to tint warnings and errors; other
      // consumers such as the Vite plugin keep the plain pass-through default.
      formatter: (config.logging && config.logging.formatter) || ((level, args) => args),
    });

    /**
     * The source directory (usually 'src').
     * @type {string}
     */
    this.srcDir = path.join(this.rootDir, config.srcDir);
    /**
     * The distribution directory (usually 'dist').
     * @type {string}
     */
    this.distDir = path.join(this.rootDir, config.distDir);
    /**
     * The directory containing core runtime files.
     * @type {string}
     */
    this.coreDir = path.join(__dirname, 'core');

    /**
     * @type {object}
     */
    this.config = config;
    /**
     * @type {StyleProcessor}
     */
    this.styleProcessor = new StyleProcessor(config.style || {}, config);
    /**
     * @type {ComponentParser}
     */
    this.componentParser = new ComponentParser(this.styleProcessor, config.voidTags, config);

    /**
     * The build mode. Production is the default: `avenx build` is what runs in
     * CI and in a deploy step, so the safe default is the optimised output.
     * `avenx serve` and `avenx watch` opt into development explicitly.
     * @type {'production'|'development'}
     */
    this.mode = resolveMode(config);

    /**
     * True when building optimised output.
     * @type {boolean}
     */
    this.production = this.mode === 'production';
    // The parser decides whether to emit an action's body text alongside its
    // compiled form, so it has to know which kind of build this is. A parser
    // used on its own -- the Vite plugin, `loadComponent`, a unit test -- leaves
    // this undefined and keeps the bodies, which is the safe direction: the
    // text is only ever redundant, never required.
    this.componentParser.production = this.production;

    this.init();
  }

  /**
   * Initializes the compiler environment, ensuring required directories exist.
   * @private
   */
  init() {
    if (!fs.existsSync(this.distDir)) {
      try {
        fs.mkdirSync(this.distDir, { recursive: true });
      } catch {
        logger.error(`❌ ${new BuildError(AvenxErrorCodes.COMPILER_DIST_CREATION_FAILED, this.distDir).message}`);
      }
    }
  }

  /**
   * Executes the full build process.
   *
   * Every fatal condition throws. Nothing here logs an error and returns as if
   * the build had finished: a caller that cannot tell success from failure
   * cannot set an exit code, and a pipeline that cannot see the failure
   * deploys whatever is already in the output directory.
   * @returns {{mode: string, distDir: string, files: string[], durationMs: number}}
   *   A description of what was written.
   * @throws {BuildError} When the application cannot be compiled.
   */
  build() {
    logger.info(`--- Avenx-JS Compiler (${this.mode}) ---`);
    const startTime = performance.now();

    if (!fs.existsSync(this.srcDir)) {
      throw new BuildError(AvenxErrorCodes.COMPILER_SRC_DIR_MISSING, this.srcDir);
    }

    this.styleProcessor.reset();
    this.componentParser.renderFallbacks = [];
    this.componentParser.expressionGaps = [];
    this.__bridgeConsumerFiles = null;
    this.__bridgeConsumerSources = null;
    this.beginModel();

    /**
     * The modules the compiler generates, keyed by the path they stand for.
     *
     * A component's module is keyed by the component file's own path, so an
     * `import './counter.component.js'` written by a developer resolves to the
     * compiled class rather than to the template source, which is not
     * JavaScript and would not parse.
     * @type {Map<string, string>}
     */
    const virtualModules = new Map();

    /** @type {Array<{name: string, file: string, kind: string}>} */
    const registrations = [];

    const bridgeData = this.processBridges(virtualModules, registrations);
    this.componentParser.setBridges(bridgeData.bridges);
    // Every component and page name, discovered up front so a template tag can
    // be validated against the whole project (AVX_W46) as each file is parsed.
    this.collectComponentNames();
    this.validateBridgeUsage(bridgeData.bridges);
    // Guards resolve their bridge imports the way everything else does now:
    // through the module graph, from the import the developer wrote.
    this.__guardBridges = bridgeData.bridges;
    this.processGuards(virtualModules);
    this.processComponents(virtualModules);
    this.processPages(virtualModules, registrations);

    const jsFileName = `${this.outputName}.js`;
    const jsMapFileName = `${this.outputName}.js.map`;
    const cssFileName = `${this.outputName}.css`;
    const traceMapFileName = sidecarFileName(this.outputName);
    const atlasMapFileName = atlasFileName(this.outputName);

    const shouldEmitJsMap =
      this.config.sourceMap === true ||
      this.config.sourcemap === true ||
      (!this.production && this.config.sourceMap !== false);

    const entryId = this.buildEntryModule(virtualModules, registrations);

    const result = this.runBundler({
      entryId,
      virtualModules,
      sourceMap: shouldEmitJsMap,
      file: jsFileName,
    });

    let bundleJs = result.code;
    if (shouldEmitJsMap) {
      bundleJs += `\n//# sourceMappingURL=${jsMapFileName}\n`;
    }

    const isDevMode =
      !this.production ||
      (this.config.style &&
        (this.config.style.dev === true ||
          this.config.style.inlineSourceMap === true ||
          this.config.style.sourceMap === 'inline'));

    const baseCssContent = this.styleProcessor.getGlobalStyles({
      dev: isDevMode,
      distDir: this.distDir,
      cssFileName,
      includeSources: this.includedStylesheets(result.included),
    });
    const sourceMap = this.styleProcessor.getSourceMap(this.distDir, cssFileName);
    const cssWithMapComment = isDevMode ? baseCssContent : baseCssContent + `\n/*# sourceMappingURL=${cssFileName}.map */\n`;

    // The trace sidecar maps recorded action and computed names back to a file
    // and a line. It sits beside the bundle and is never referenced by it, so
    // an application that records no traces downloads nothing extra and a
    // deployment that does not want the file simply does not upload it.
    const traceSidecar = buildSidecar(this.componentParser.locations, bridgeData.bridges, this.rootDir);

    // The Atlas is finished here, after every unit has been parsed, and sits
    // beside the bundle on the same terms as the trace sidecar: never
    // referenced by it, so it costs a browser nothing.
    this.reportRenderFallbacks();
    this.reportExpressionGaps();

    this.finishModel();
    reportAtlasDiagnostics(this.model, this.config);
    reportRewindDiagnostics(this.model, this.config);
    const atlasJson = serializeAtlas(this.model, {
      srcDir: path.relative(this.rootDir, this.srcDir).split(path.sep).join('/') || '.',
    });

    const files = [jsFileName, cssFileName, `${cssFileName}.map`, traceMapFileName, atlasMapFileName];
    if (shouldEmitJsMap) {
      files.push(jsMapFileName);
    }

    const outputs = new Map([
      [jsFileName, bundleJs],
      [cssFileName, cssWithMapComment],
      [`${cssFileName}.map`, JSON.stringify(sourceMap, null, 2)],
      [traceMapFileName, JSON.stringify(traceSidecar, null, 2)],
      [atlasMapFileName, atlasJson],
    ]);

    if (shouldEmitJsMap) {
      outputs.set(jsMapFileName, JSON.stringify(result.map, null, 2));
    }

    // A build may not report success unless what it produced parses. The check
    // runs before anything is written, so a bundle that does not parse never
    // reaches distDir and the previous build is left intact.
    assertValidOutputs(outputs);

    // Nor unless it contains the runtime capabilities it decided to link. The
    // build tells the developer their component renders through the string
    // renderer; if that renderer is not in the artifact, the honest outcome is
    // a failed build rather than an application that mounts and shows nothing.
    assertRuntimeCapabilities(outputs, this.linkedRuntimeCapabilities());

    // Every artifact is produced before any of them is written, and written to
    // a staging directory before any of them is promoted. Writing bundle.js
    // first and then failing while producing the CSS used to leave a new script
    // beside a stale stylesheet — output that never existed as a whole build.
    const staging = this.writeStaging(outputs);

    try {
      this.reportBundleStats(result.stats);
      logger.info('\nAsset sizes:');

      files.forEach((file) => {
        const filePath = path.join(staging, file);
        const bytes = fs.statSync(filePath).size;
        const sizeKb = bytes / 1024;

        // The transferred size is what a browser actually pays, and it is the
        // number that moved least when the bundler replaced esbuild's mangling
        // minifier with Avenx's conservative one. Reporting only the raw figure
        // would tell a developer the wrong story about their own bundle.
        const transferred =
          file.endsWith('.js') || file.endsWith('.css')
            ? ` (${(zlib.gzipSync(fs.readFileSync(filePath)).length / 1024).toFixed(2)} KB gzipped)`
            : '';

        logger.info(`${file}: ${sizeKb.toFixed(2)} KB${transferred}`);

        // The trace sidecar and the Atlas are build artifacts for the CLI,
        // not something a browser downloads, so neither is weighed against the
        // bundle budget.
        if (file !== traceMapFileName && file !== atlasMapFileName && sizeKb > this.bundleSizeWarningKb()) {
          // Escalating AVX_W01 to an error throws from here. That happens
          // before promotion, so the size limit is enforced on output that
          // never reaches distDir.
          reportWarning(
            AvenxErrorCodes.COMPILER_BUNDLE_SIZE_EXCEEDED,
            new BuildError(
              AvenxErrorCodes.COMPILER_BUNDLE_SIZE_EXCEEDED,
              file,
              this.bundleSizeWarningKb(),
              sizeKb.toFixed(2),
            ),
            this.config,
          );
        }
      });

      this.promoteStaging(staging, files);
    } finally {
      fs.rmSync(staging, { recursive: true, force: true });
    }

    logger.info('-----------------------');
    logger.info(`\nBuild successful: ${this.distDir}/${jsFileName} & ${this.distDir}/${cssFileName}`);

    const endTime = performance.now();
    const durationMs = Math.round(endTime - startTime);
    logger.info(`Build completed in ${durationMs} ms`);

    return { mode: this.mode, distDir: this.distDir, files, durationMs, bundle: result.stats };
  }

  /**
   * The runtime capabilities this build linked, and how to see them in output.
   *
   * Each entry pairs a capability the compiler put into the entry graph with
   * evidence that can only appear if the module providing it was linked *and*
   * ran. Evidence of the call, never of the declaration: `AvenxComponent`
   * imports the registry in order to read from it, so the registry module --
   * and the text of `installStringRenderer(classes)` inside it -- is in every
   * bundle whether or not anything ever fills it.
   * @returns {Array<{capability: string, reason: string, evidence: string}>}
   *   What the emitted bundle has to contain.
   * @private
   */
  linkedRuntimeCapabilities() {
    const required = [];

    const fallbacks = this.componentParser.renderFallbacks;
    if (fallbacks.length > 0) {
      required.push({
        capability: 'the string renderer',
        reason: `${fallbacks.length} template(s) could not be compiled to a render program (AVX_W47)`,
        // The installer passes an object literal; the declaration takes a
        // named parameter. Only the former can match.
        evidence: 'installStringRenderer\\(\\s*\\{',
      });
    }

    return required;
  }

  /**
   * The size above which the build warns about the JavaScript it produced.
   *
   * Configurable because the honest number depends on what an application is.
   * The default is a ceiling for the whole bundle including the runtime, not a
   * budget for application code, and a threshold nothing can satisfy is a
   * threshold everyone learns to ignore.
   * @returns {number} The threshold in KB.
   * @private
   */
  bundleSizeWarningKb() {
    const configured = this.config && this.config.bundleSizeWarningKb;
    return typeof configured === 'number' && configured > 0 ? configured : BUNDLE_SIZE_WARNING_THRESHOLD_KB;
  }

  /**
   * Assembles the module the bundle is rooted at.
   *
   * `main.app.js` stays the developer's file. What the compiler discovered for
   * them -- the pages under `src/pages/`, the bridges something imports -- is
   * added as ordinary imports and registration calls, in the same place the old
   * pipeline injected them.
   * @param {Map<string, string>} virtualModules - The generated module table.
   * @param {Array<object>} registrations - Units to import and register.
   * @returns {string} The entry module's id.
   * @private
   */
  buildEntryModule(virtualModules, registrations) {
    const prelude = [];

    const globalsId = path.join(this.srcDir, '__avenx_globals__.js');
    virtualModules.set(globalsId, globalsModule(PUBLIC_GLOBALS, NAMESPACE_GLOBAL));
    prelude.push(globalsId);

    // Development builds carry the trace recorder, because `avenx serve
    // --trace` installs it through the namespace. Production builds do not
    // reference it, so it is shaken out -- which is the difference between the
    // two modes, and the whole of it.
    if (!this.production) {
      const devtoolsId = path.join(this.srcDir, '__avenx_devtools__.js');
      virtualModules.set(devtoolsId, devtoolsModule(NAMESPACE_GLOBAL));
      prelude.push(devtoolsId);

      // The expression interpreter, for the same reason and on the same terms.
      // A development build keeps rendering an expression the generator could
      // not compile -- the build already said which, as AVX_W48. A production
      // build references nothing here, so the parser, the tree-walking
      // evaluator and the old source-text sandbox leave the graph, and with
      // them the last `new Function` in the framework.
      const interpreterId = path.join(this.srcDir, '__avenx_interpreter__.js');
      virtualModules.set(interpreterId, interpreterModule());
      prelude.push(interpreterId);
    }

    // The string renderer, only when something in this build still needs it.
    // `renderFallbacks` is populated by the component parser as it compiles, so
    // by the time the entry module is built the answer is known exactly: a
    // build where every template lowered does not reference the renderer, and
    // the bundler shakes out the patcher, the list manager, the defer manager
    // and the template renderer with it.
    if (this.componentParser.renderFallbacks.length > 0) {
      const stringRendererId = path.join(this.srcDir, '__avenx_string_renderer__.js');
      virtualModules.set(stringRendererId, stringRendererModule());
      prelude.push(stringRendererId);
    }

    // Built-in components, only the ones this build's templates reference.
    for (const [tag, specifier] of BUILTIN_COMPONENT_MODULES) {
      if (!this.componentParser.referencedComponents.has(tag)) continue;
      const builtinId = path.join(this.srcDir, `__avenx_builtin_${tag}__.js`);
      virtualModules.set(builtinId, builtinComponentModule(specifier, tag));
      prelude.push(builtinId);
    }

    const rewindSettings = this.rewindSettings();
    if (rewindSettings) {
      const rewindId = path.join(this.srcDir, '__avenx_rewind__.js');
      virtualModules.set(rewindId, rewindConfigModule(rewindSettings));
      prelude.push(rewindId);
    }

    const mainFile = path.join(this.srcDir, 'main.app.js');
    const source = fs.existsSync(mainFile) ? replaceEnvVariables(fs.readFileSync(mainFile, 'utf-8')) : '';

    const entryId = fs.existsSync(mainFile) ? mainFile : path.join(this.srcDir, '__avenx_entry__.js');
    virtualModules.set(entryId, entryModule({ source, registrations, prelude }));
    return entryId;
  }

  /**
   * Runs the bundler and translates its failures into build diagnostics.
   *
   * Each of these used to be silence. An unresolvable import was deleted, a
   * mistyped named import became `undefined`, and the build said it had
   * succeeded. Turning them into coded, located errors is the point of the
   * migration, so the translation is explicit rather than a generic wrapper.
   * @param {object} options - Bundling options.
   * @param {string} options.entryId - The entry module id.
   * @param {Map<string, string>} options.virtualModules - Generated modules.
   * @param {boolean} options.sourceMap - Whether to emit a source map.
   * @param {string} options.file - The output file name.
   * @returns {{code: string, map: object|null, stats: object}} The bundle.
   * @throws {BuildError} When the application does not link.
   * @private
   */
  runBundler({ entryId, virtualModules, sourceMap, file }) {
    const treeShake = !(this.config && (this.config.treeShake === false || this.config.treeShakeComponents === false));
    const shouldMinify =
      this.config && typeof this.config.minify === 'boolean' ? this.config.minify : this.production;

    try {
      return bundle({
        entries: [entryId],
        virtualModules,
        rootDir: this.rootDir,
        treeShake,
        minify: shouldMinify,
        sourceMap,
        file,
      });
    } catch (error) {
      throw this.describeBundleFailure(error);
    }
  }

  /**
   * Turns a bundler error into a located BuildError.
   * @param {Error} error - What the bundler threw.
   * @returns {Error} A BuildError, or the original when it is not ours.
   * @private
   */
  describeBundleFailure(error) {
    const relative = (file) => (file && path.isAbsolute(file) ? path.relative(this.rootDir, file) : file || 'the bundle');

    if (error instanceof ResolveError) {
      return new BuildError(
        AvenxErrorCodes.COMPILER_UNRESOLVED_IMPORT,
        error.specifier,
        relative(error.importer),
        error.reason,
      );
    }
    if (error instanceof BindingError) {
      return new BuildError(
        AvenxErrorCodes.COMPILER_MISSING_EXPORT,
        relative(error.importer),
        error.message,
        relative(error.importer),
      );
    }
    if (error instanceof DynamicImportError) {
      return new BuildError(AvenxErrorCodes.COMPILER_UNRESOLVED_IMPORT, 'a computed dynamic import', relative(error.importer), error.message);
    }
    if (error instanceof ModuleParseError) {
      return new BuildError(AvenxErrorCodes.COMPILER_MODULE_UNREADABLE, relative(error.file), error.message);
    }
    if (error instanceof EmitError) {
      // A live-binding collision is two modules publishing one bundle-scope
      // name, which is what AVX_C16 has always described.
      if (error.kind === 'live-binding-collision') {
        return new BuildError(AvenxErrorCodes.COMPILER_DUPLICATE_BUNDLE_BINDING, error.message, relative(error.file), '');
      }

      // A cycle between bridges is a bridge problem, and saying so is more use
      // than the general module-cycle message. Bridge cycles were always fatal
      // and stay fatal: a bridge's default export is a value, and a value
      // cannot cross a cycle in a browser either.
      const bridgeCycle = (error.cycles || []).find(
        (cycle) => cycle.length > 1 && cycle.every((id) => id.endsWith('.bridge.js')),
      );
      if (bridgeCycle) {
        const names = bridgeCycle.map((id) => path.basename(id, '.bridge.js'));
        return new BuildError(AvenxErrorCodes.COMPILER_BRIDGE_CIRCULAR_IMPORT, names.join(' -> '));
      }

      return new BuildError(AvenxErrorCodes.COMPILER_BUNDLE_CYCLE, error.message, relative(error.file));
    }
    return error;
  }

  /**
   * The stylesheets belonging to units that reached the bundle.
   *
   * Every component is compiled so Atlas can describe the project as written,
   * which means the style processor has seen more stylesheets than the
   * application uses. A component the bundler shook out must not leave its CSS
   * behind: the old pipeline only ever parsed the components it kept, so
   * omitting them here preserves what a developer actually observed.
   * @param {Set<string>} included - Module ids that reached the bundle.
   * @returns {Set<string>} Absolute paths of the stylesheets to emit.
   * @private
   */
  includedStylesheets(included) {
    const sheets = new Set();
    for (const id of included) {
      const match = /\.(component|page)\.js$/.exec(id);
      if (!match) continue;
      sheets.add(id.replace(/\.(component|page)\.js$/, `.${match[1]}.css`));
    }
    return sheets;
  }

  /**
   * Reports what the bundle contains, and what was left out.
   *
   * The old build could not have printed this: the runtime arrived as one
   * prebuilt file, so there was no count of modules and no notion of a module
   * being dropped. Printing it now is the same house rule the render-program
   * fallback follows -- say what the build did, including what it removed.
   * @param {object} stats - The bundler's report.
   * @private
   */
  reportBundleStats(stats) {
    if (!stats) return;
    const parts = [`${stats.modulesEmitted} modules`];
    if (stats.modulesShaken > 0) {
      parts.push(`${stats.modulesShaken} shaken out`);
    }
    if (stats.externals > 0) {
      parts.push(`${stats.externals} from node_modules`);
    }
    logger.info(`\nBundled ${parts.join(' · ')}`);
  }

  /**
   * Reports templates that could not be compiled to a render program.
   *
   * A component without a program still renders correctly -- it takes the
   * string renderer, which re-renders and re-diffs the whole template on every
   * update. That is a real and invisible cost, so it is named. The same house
   * rule Atlas follows: an analysis that stopped short says where.
   * @private
   */
  reportRenderFallbacks() {
    const fallbacks = this.componentParser.renderFallbacks;
    if (!fallbacks || fallbacks.length === 0) {
      return;
    }

    // Grouped by reason rather than listed per component: "eleven templates
    // contain a list" is one thing to act on, eleven lines are eleven.
    const byReason = new Map();
    for (const entry of fallbacks) {
      if (!byReason.has(entry.reason)) {
        byReason.set(entry.reason, []);
      }
      byReason.get(entry.reason).push(entry.name);
    }

    const detail = [...byReason.entries()]
      .sort((a, b) => b[1].length - a[1].length)
      .map(([reason, names]) => `  ${reason}: ${names.sort().join(', ')}`)
      .join('\n');

    reportWarning(
      AvenxErrorCodes.COMPILER_RENDER_NOT_COMPILED,
      new BuildError(AvenxErrorCodes.COMPILER_RENDER_NOT_COMPILED, fallbacks.length, detail),
      this.config,
    );
  }

  /**
   * Reports what the expression generator could not compile.
   *
   * A security refusal fails the build: an expression naming `window` or
   * writing `__proto__` is a mistake in the application, and the developer
   * should learn about it here rather than from a sandbox violation the first
   * time that branch executes.
   *
   * A language gap is a warning. The expression still works -- it is
   * interpreted at runtime as it always was -- but it is the reason the
   * interpreter is still in the bundle, so the cost is named rather than
   * absorbed silently. Same house rule as AVX_W47 above.
   * @throws {BuildError} When an expression was refused for a security reason.
   * @private
   */
  reportExpressionGaps() {
    const units = this.componentParser.expressionGaps;
    if (!units || units.length === 0) {
      return;
    }

    for (const unit of units) {
      for (const refusal of unit.refusals) {
        throw new BuildError(
          AvenxErrorCodes.COMPILER_EXPRESSION_REFUSED,
          refusal.source,
          refusal.reason,
          unit.name,
        );
      }
    }

    const gaps = units.flatMap((unit) => unit.gaps.map((gap) => ({ ...gap, name: unit.name })));
    if (gaps.length === 0) {
      return;
    }

    const detail = gaps
      .map((gap) => `  <${gap.name}> ${JSON.stringify(gap.source)} - ${gap.reason}`)
      .sort()
      .join('\n');

    reportWarning(
      AvenxErrorCodes.COMPILER_EXPRESSION_NOT_COMPILED,
      new BuildError(AvenxErrorCodes.COMPILER_EXPRESSION_NOT_COMPILED, gaps.length, detail),
      this.config,
    );
  }

  /**
   * The Rewind journal settings this project overrides, if any.
   *
   * A project that leaves `rewind` alone produces no configuration module at
   * all, so the defaults baked into the journal are the only thing shipped.
   * @returns {object|null} The settings, or null when there are none.
   * @private
   */
  rewindSettings() {
    const rewind = this.config && this.config.rewind;
    if (!rewind) return null;

    const settings = {};
    if (rewind.onConflict && rewind.onConflict !== 'safe') {
      settings.onConflict = rewind.onConflict;
    }
    if (typeof rewind.maxSnapshotItems === 'number' && rewind.maxSnapshotItems !== 10000) {
      settings.maxSnapshotItems = rewind.maxSnapshotItems;
    }
    return Object.keys(settings).length > 0 ? settings : null;
  }

  /**
   * Starts a fresh Atlas model for this run and attaches it to the parser.
   *
   * Called at the top of both `build` and `analyze` so a compiler instance can
   * be reused — `avenx watch` does exactly that — without the second run
   * inheriting the first run's nodes.
   * @returns {AppModel} The model being populated.
   */
  beginModel() {
    this.model = new AppModel();
    this.componentParser.setRootDir(this.rootDir);
    this.componentParser.setModel(this.model);
    return this.model;
  }

  /**
   * Completes the Atlas model once every unit has been parsed.
   *
   * Render edges are resolved here rather than during parsing because a
   * component's template can name a child that has not been compiled yet, and
   * an edge to a node that does not exist yet would be dropped.
   * @returns {AppModel} The finished model.
   * @private
   */
  finishModel() {
    const known = new Map();
    for (const unit of this.componentParser.__atlasUnits) {
      known.set(unit.name, `${unit.kind}:${unit.name}`);
    }

    for (const unit of this.componentParser.__atlasUnits) {
      addRenderEdges(this.model, {
        ownerId: `${unit.kind}:${unit.name}`,
        content: unit.content,
        masked: unit.masked,
        starts: unit.starts,
        file: path.relative(this.rootDir, unit.filePath).split(path.sep).join('/'),
        known,
      });
    }

    addRoutesAndGuards(this.model, { srcDir: this.srcDir, rootDir: this.rootDir });

    return this.model;
  }

  /**
   * Builds the Atlas model without emitting a bundle.
   *
   * `avenx atlas`, `avenx impact` and `avenx why` need the model and nothing
   * else, and writing a bundle to answer a query would be both slow and rude
   * — it would overwrite whatever is in `dist/`. The work here is the same
   * parse the build performs, so the two can never disagree.
   * @param {object} [options] - Analysis options.
   * @param {boolean} [options.tolerant] - Record a failing phase on the model
   *   and carry on, rather than throwing. Defaults to true, because a query is
   *   often asked precisely because the project is broken.
   * @returns {AppModel} The finished model.
   */
  analyze(options = {}) {
    if (!fs.existsSync(this.srcDir)) {
      throw new BuildError(AvenxErrorCodes.COMPILER_SRC_DIR_MISSING, this.srcDir);
    }

    const tolerant = options.tolerant !== false;
    this.__analyzing = true;

    this.styleProcessor.reset();
    this.__bridgeConsumerFiles = null;
    this.__bridgeConsumerSources = null;
    this.beginModel();

    // A query is asked *about* code, often precisely because something is
    // wrong with it. A malformed bridge should cost the model that bridge, not
    // the whole answer, so each phase is allowed to fail on its own and the
    // failure is recorded where the caller can report it.
    const phase = (name, run) => {
      try {
        return run();
      } catch (err) {
        if (!tolerant) throw err;
        this.model.errors.push({
          phase: name,
          code: err.code || 'AVX_UNK',
          message: String(err.message || err),
        });
        return null;
      }
    };

    try {
      const bridgeData = phase('bridges', () => this.processBridges());
      this.componentParser.setBridges((bridgeData && bridgeData.bridges) || new Map());
      phase('componentNames', () => this.collectComponentNames());
      phase('components', () => this.processComponents());
      phase('pages', () => this.processPages());
      return this.finishModel();
    } finally {
      this.__analyzing = false;
    }
  }

  /**
   * Writes the finished artifacts to a staging directory.
   *
   * Staging lives inside distDir so that promotion is a rename on the same
   * filesystem — a rename across devices fails with EXDEV, which would put the
   * promote step back in the business of copying half a build.
   * @param {Map<string, string>} outputs - File name to contents.
   * @returns {string} The staging directory path.
   * @throws {BuildError} When the output directory cannot be written to.
   * @private
   */
  writeStaging(outputs) {
    const staging = path.join(this.distDir, `.avenx-staging-${process.pid}`);

    try {
      fs.rmSync(staging, { recursive: true, force: true });
      fs.mkdirSync(staging, { recursive: true });

      for (const [fileName, contents] of outputs) {
        fs.writeFileSync(path.join(staging, fileName), contents);
      }
    } catch (err) {
      fs.rmSync(staging, { recursive: true, force: true });
      throw new BuildError(AvenxErrorCodes.COMPILER_DIST_CREATION_FAILED, `${this.distDir} (${err.message})`);
    }

    return staging;
  }

  /**
   * Moves staged artifacts into the output directory.
   *
   * Each file is renamed into place, which is atomic per file. A build that
   * fails before this point leaves distDir exactly as it was, so the previous
   * artifacts stay whole rather than being partly overwritten by a build that
   * never finished.
   *
   * The previous artifacts are deliberately not deleted on failure. The exit
   * code is what stops a deployment; removing a good bundle would break
   * anything still serving it — a dev server, a local preview, a rollback —
   * and would turn a build error into a second, unrelated outage.
   * @param {string} staging - The staging directory.
   * @param {string[]} files - File names to promote.
   * @throws {BuildError} When a staged file cannot be moved into place.
   * @private
   */
  promoteStaging(staging, files) {
    for (const fileName of files) {
      const from = path.join(staging, fileName);
      const to = path.join(this.distDir, fileName);
      try {
        fs.renameSync(from, to);
      } catch (err) {
        throw new BuildError(AvenxErrorCodes.COMPILER_DIST_CREATION_FAILED, `${to} (${err.message})`);
      }
    }
  }

  /**
   * Processes bridge registrations from the global directory.
   * @returns {{registrations: string}} The registration code for bridges.
   * @private
   */
  /**
   * Collects every `.bridge.js` module in the project.
   * Bridges live in `src/bridges/` (preferred) or alongside guards in
   * `src/global/`; both are scanned recursively.
   * @returns {string[]} Absolute paths to bridge modules.
   * @private
   */
  findBridgeFiles() {
    const files = [];
    const scan = (dir) => {
      if (!fs.existsSync(dir)) return;
      for (const entry of fs.readdirSync(dir)) {
        const fullPath = path.join(dir, entry);
        if (fs.statSync(fullPath).isDirectory()) {
          scan(fullPath);
        } else if (entry.endsWith('.bridge.js')) {
          files.push(fullPath);
        }
      }
    };
    scan(path.join(this.srcDir, 'bridges'));
    scan(path.join(this.srcDir, 'global'));
    return files;
  }

  /**
   * Collects the files that may import a bridge: components, pages, the app
   * entry point and other bridges.
   * @returns {string[]} Absolute paths to candidate consumer files.
   * @private
   */
  findBridgeConsumerFiles() {
    if (this.__bridgeConsumerFiles) {
      return this.__bridgeConsumerFiles;
    }
    const files = [];
    const scan = (dir, ext) => {
      if (!fs.existsSync(dir)) return;
      for (const entry of fs.readdirSync(dir)) {
        const fullPath = path.join(dir, entry);
        if (fs.statSync(fullPath).isDirectory()) {
          scan(fullPath, ext);
        } else if (entry.endsWith(ext)) {
          files.push(fullPath);
        }
      }
    };
    scan(path.join(this.srcDir, 'components'), '.component.js');
    scan(path.join(this.srcDir, 'pages'), '.page.js');
    // Guards import bridges too. Leaving them out of consumer discovery made a
    // bridge whose only importer was a guard look unreachable, so it was
    // tree-shaken out of the bundle and the guard's alias resolved to an
    // undefined identifier -- a ReferenceError that stopped the application
    // booting at all.
    scan(path.join(this.srcDir, 'guards'), '.guard.js');
    scan(path.join(this.srcDir, 'global'), '.guard.js');

    const mainFile = path.join(this.srcDir, 'main.app.js');
    if (fs.existsSync(mainFile)) {
      files.push(mainFile);
    }
    this.__bridgeConsumerFiles = files;
    return files;
  }

  /**
   * Reads a bridge consumer's source, caching it for the duration of a build.
   * Reachability and usage validation both need every consumer's text.
   * @param {string} filePath - The consumer file.
   * @returns {string} Its source.
   * @private
   */
  readBridgeConsumer(filePath) {
    if (!this.__bridgeConsumerSources) {
      this.__bridgeConsumerSources = new Map();
    }
    let source = this.__bridgeConsumerSources.get(filePath);
    if (source === undefined) {
      source = fs.readFileSync(filePath, 'utf-8');
      this.__bridgeConsumerSources.set(filePath, source);
    }
    return source;
  }

  /**
   * Processes bridge modules.
   *
   * A bridge file is already valid JavaScript, so this generates a module that
   * is almost the file itself: its default export is named so the runtime can
   * be told what to call it, and that is all. Its own imports -- the runtime,
   * other bridges -- stay as written and become real edges in the graph, which
   * is what replaced the old alias-and-concatenate scheme along with its
   * hand-rolled cycle detection and emission ordering.
   * @param {Map<string, string>} [virtualModules] - Generated module table to fill.
   * @param {Array<object>} [registrations] - Units to import and register.
   * @returns {{bridges: Map<string, object>}} The descriptor of every bridge,
   *   keyed by absolute path.
   * @private
   */
  processBridges(virtualModules = null, registrations = null) {
    /** @type {Map<string, object>} */
    const bridges = new Map();

    for (const filePath of this.findBridgeFiles()) {
      const descriptor = analyzeBridgeFile(filePath, replaceEnvVariables);
      if (!descriptor) {
        throw new BuildError(
          AvenxErrorCodes.COMPILER_BRIDGE_INVALID_MODULE,
          path.relative(this.rootDir, filePath),
        );
      }

      bridges.set(path.resolve(filePath), descriptor);
    }

    // Every bridge enters the model, reachable or not: `avenx atlas` should
    // describe the project as written, and a bridge omitted from the bundle
    // for having no importer is exactly what `inspect` needs to report.
    if (this.model) {
      for (const descriptor of bridges.values()) {
        addBridgeUnit(this.model, descriptor, {
          rootDir: this.rootDir,
          source: replaceEnvVariables(fs.readFileSync(descriptor.filePath, 'utf-8')),
          bridges,
        });
      }
    }

    // Bridge names become identifiers in the bundle, so they have to be unique.
    const byName = new Map();
    for (const descriptor of bridges.values()) {
      if (!byName.has(descriptor.name)) {
        byName.set(descriptor.name, []);
      }
      byName.get(descriptor.name).push(descriptor.filePath);
    }
    const duplicates = [...byName.entries()].filter(([, paths]) => paths.length > 1);
    if (duplicates.length > 0) {
      const details = duplicates
        .map(([name, paths]) => `  "${name}":\n${paths.map((item) => `    - ${item}`).join('\n')}`)
        .join('\n');
      throw new BuildError(AvenxErrorCodes.COMPILER_BRIDGE_DUPLICATE_NAME, details);
    }

    // Reachability: a modern bridge ships only when something imports it,
    // directly or through another bridge.
    const reachable = new Set();
    const visit = (resolvedPath) => {
      const key = path.resolve(resolvedPath);
      if (reachable.has(key)) return;
      const descriptor = bridges.get(key);
      if (!descriptor) return;
      reachable.add(key);
      for (const entry of descriptor.bridgeImports) {
        visit(entry.resolved);
      }
    };

    for (const consumer of this.findBridgeConsumerFiles()) {
      const source = this.readBridgeConsumer(consumer);
      for (const entry of findBridgeImports(consumer, source)) {
        if (!bridges.has(path.resolve(entry.resolved))) {
          throw new BuildError(
            AvenxErrorCodes.COMPILER_BRIDGE_NOT_FOUND,
            entry.specifier,
            path.relative(this.rootDir, consumer),
            entry.resolved,
            [...bridges.values()].map((item) => item.name).join(', ') || 'none',
          );
        }
        visit(entry.resolved);
      }
    }

    // Emission order and cycle detection are the bundler's job now: it orders
    // the whole graph topologically and reports a cycle that carries a binding
    // which cannot cross it, rather than every cycle regardless of what it
    // carries. A bridge module is generated for each reachable bridge and the
    // graph decides the rest.
    for (const key of reachable) {
      const descriptor = bridges.get(key);
      if (!descriptor) continue;
      logger.info(`[Bridge] ${descriptor.name}`);

      if (virtualModules) {
        const source = replaceEnvVariables(fs.readFileSync(descriptor.filePath, 'utf-8'));
        virtualModules.set(
          path.resolve(descriptor.filePath),
          bridgeModule({ name: descriptor.name, binding: descriptor.binding, source }),
        );
      }
      if (registrations) {
        registrations.push({ name: descriptor.name, file: path.resolve(descriptor.filePath), kind: 'bridge' });
      }
    }

    const unused = [...bridges.values()].filter((item) => !reachable.has(path.resolve(item.filePath)));
    for (const descriptor of unused) {
      logger.info(`[Bridge] ${descriptor.name} — not imported anywhere, omitted from the bundle`);
    }

    return { bridges };
  }

  /**
   * Reports template members and event subscriptions that a bridge does not
   * declare. These are silent `undefined` reads at runtime, so they are worth
   * surfacing at build time with a suggestion.
   * @param {Map<string, object>} bridges - Discovered bridges by absolute path.
   * @private
   */
  validateBridgeUsage(bridges) {
    if (bridges.size === 0) return;

    for (const consumer of this.findBridgeConsumerFiles()) {
      const source = this.readBridgeConsumer(consumer);
      const imports = findBridgeImports(consumer, source);
      if (imports.length === 0) continue;

      const relative = path.relative(this.rootDir, consumer);
      // Scan the body only: an import specifier such as './auth.bridge.js'
      // would otherwise look like a member access on `auth`.
      const body = source.replace(/^[ \t]*import\s+(?:[\s\w$,{}*]*?\s+from\s+)?['"][^'"]*['"];?[ \t]*\r?\n?/gm, '');
      const byLocal = new Map();
      for (const entry of imports) {
        const descriptor = bridges.get(path.resolve(entry.resolved));
        if (descriptor) {
          byLocal.set(entry.local, descriptor);
        }
      }

      for (const [local, descriptor] of byLocal) {
        const members = declaredMembers(descriptor);
        const accessRegex = new RegExp(`\\b${local}\\s*(?:\\?\\.|\\.)\\s*([A-Za-z_$][\\w$]*)`, 'g');
        const reported = new Set();
        let match;
        while ((match = accessRegex.exec(body)) !== null) {
          const member = match[1];
          if (members.includes(member) || reported.has(member)) continue;
          reported.add(member);
          reportWarning(
            AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_MEMBER,
            new BuildError(
              AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_MEMBER,
              descriptor.name,
              member,
              relative,
              suggestName(member, members),
              members.join(', '),
            ),
            this.config,
          );
        }
      }

      for (const { target, event } of extractSubscriptions(body)) {
        const descriptor = byLocal.get(target);
        if (!descriptor || descriptor.events.includes(event)) continue;
        reportWarning(
          AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_EVENT,
          new BuildError(
            AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_EVENT,
            descriptor.name,
            event,
            relative,
            suggestName(event, descriptor.events),
            descriptor.events.join(', ') || 'none',
          ),
          this.config,
        );
      }
    }
  }

  /**
   * Registers guard modules with environment variables substituted.
   *
   * A guard file is already valid JavaScript -- `export default class AuthGuard
   * extends AvenxGuard` with ordinary imports -- so nothing is rewritten. The
   * bridge alias machinery this replaced existed only because
   * `rewriteRuntimeImports` deleted a guard's bridge import and left the local
   * name undefined, which reported AVX_R07 on every navigation through it.
   * With imports resolved rather than deleted, the import means what it says.
   *
   * A guard nothing imports is no longer emitted at all, where the old pipeline
   * concatenated every `.guard.js` in the project whether or not a route used
   * it.
   * @param {Map<string, string>} virtualModules - Generated module table to fill.
   * @returns {void}
   * @private
   */
  processGuards(virtualModules) {
    const scan = (dir) => {
      if (!fs.existsSync(dir)) return;
      for (const file of fs.readdirSync(dir)) {
        if (!file.endsWith('.guard.js')) continue;
        const filePath = path.join(dir, file);
        const name = path
          .basename(file, '.guard.js')
          .split(/[-_]/)
          .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
          .join('') + 'Guard';
        logger.info(`[Guard] ${name}`);
        virtualModules.set(path.resolve(filePath), replaceEnvVariables(fs.readFileSync(filePath, 'utf-8')));
      }
    };

    scan(path.join(this.srcDir, 'global'));
    scan(path.join(this.srcDir, 'guards'));
  }

  /**
   * Collects every registered component and page name by filename and hands
   * the set to the parser before any file is compiled.
   *
   * The unresolved-component check (AVX_W46) runs per file, while a file is
   * being parsed, so it cannot rely on the running scan having reached every
   * sibling yet. Discovering all names up front from filenames — the same
   * PascalCase derivation `processComponents`/`processPages` use — means the
   * check sees the whole project regardless of the order files are visited or
   * whether tree shaking drops a component from the output.
   * @returns {Set<string>} The registered component and page names.
   * @private
   */
  collectComponentNames() {
    const names = new Set();

    const toClassName = (fileName, suffix) =>
      path
        .basename(fileName, suffix)
        .split(/[-_]/)
        .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
        .join('');

    const scan = (dir, suffix) => {
      if (!fs.existsSync(dir)) return;
      for (const entry of fs.readdirSync(dir)) {
        const fullPath = path.join(dir, entry);
        if (fs.statSync(fullPath).isDirectory()) {
          scan(fullPath, suffix);
        } else if (entry.endsWith(suffix)) {
          names.add(toClassName(entry, suffix));
        }
      }
    };

    scan(path.join(this.srcDir, 'components'), '.component.js');
    scan(path.join(this.srcDir, 'pages'), '.page.js');

    this.componentParser.setComponentNames(names);
    return names;
  }

  /**
   * Compiles every component in `src/components` into an ES module.
   *
   * Every component is compiled, and the bundler decides which ones ship: a
   * component is in the bundle when something imports it, transitively from the
   * entry. That replaces `findUsedComponents`, which scanned template tags and
   * import statements with regular expressions to guess the same answer, and
   * could only ever approximate it -- a component it wrongly kept was dead
   * weight, and one it wrongly dropped was a runtime failure.
   * @param {Map<string, string>} [virtualModules] - Generated module table to fill.
   * @returns {void}
   * @private
   */
  processComponents(virtualModules = null) {
    const compDir = path.join(this.srcDir, 'components');
    const classNameMap = new Map();
    const pathToClassName = new Map();

    const toClassName = (fileName) =>
      path
        .basename(fileName, '.component.js')
        .split(/[-_]/)
        .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
        .join('');

    const scan = (dir) => {
      if (!fs.existsSync(dir)) return;

      fs.readdirSync(dir).forEach((file) => {
        const fullPath = path.join(dir, file);

        if (fs.statSync(fullPath).isDirectory()) {
          scan(fullPath);
        } else if (file.endsWith('.component.js')) {
          const className = toClassName(file);

          if (!classNameMap.has(className)) {
            classNameMap.set(className, []);
          }

          classNameMap.get(className).push(fullPath);
          pathToClassName.set(path.resolve(fullPath), className);
        }
      });
    };

    scan(compDir);

    const duplicates = [...classNameMap.entries()].filter(([, paths]) => paths.length > 1);

    if (duplicates.length > 0) {
      const details = duplicates
        .map(([className, paths]) => `  "${className}":\n${paths.map((p) => `    - ${p}`).join('\n')}`)
        .join('\n');

      throw new BuildError(AvenxErrorCodes.COMPILER_DUPLICATE_COMPONENT_NAME, details);
    }

    // Build dependency graph for components
    const graph = new Map();
    classNameMap.forEach((paths, className) => {
      const fullPath = paths[0];
      const deps = new Set();
      if (fs.existsSync(fullPath)) {
        const content = fs.readFileSync(fullPath, 'utf-8');

        // Extract dependencies from JS import statements
        const importRegex = /import\s+(?:[\s\w$,{}*]*?\s+from\s+['"](.*?)['"]|['"](.*?)['"]);?/g;
        let match;
        while ((match = importRegex.exec(content)) !== null) {
          const importSpecifier = match[1] || match[2];
          if (importSpecifier && importSpecifier.startsWith('.')) {
            let resolved = path.resolve(path.dirname(fullPath), importSpecifier);
            if (!resolved.endsWith('.js')) {
              if (fs.existsSync(`${resolved}.component.js`)) {
                resolved = `${resolved}.component.js`;
              } else if (fs.existsSync(`${resolved}.js`)) {
                resolved = `${resolved}.js`;
              }
            }
            const targetClassName = pathToClassName.get(resolved);
            if (targetClassName && targetClassName !== className) {
              deps.add(targetClassName);
            }
          }
        }

        // Extract dependencies from HTML template tags (e.g. <ChildComp />)
        const tagRegex = /<([A-Z][a-zA-Z0-9]*)\b/g;
        while ((match = tagRegex.exec(content)) !== null) {
          const tagName = match[1];
          if (classNameMap.has(tagName) && tagName !== className) {
            deps.add(tagName);
          }
        }
      }
      graph.set(className, Array.from(deps));
    });

    // Cycle detection & topological sort using DFS
    const visited = new Set();
    const visiting = new Map();
    const reportedCycles = new Set();
    const orderedClasses = [];

    const dfs = (className, stack = []) => {
      if (visited.has(className)) return;
      if (visiting.has(className)) {
        const startIndex = stack.indexOf(className);
        const cyclePath = stack.slice(startIndex).concat(className);
        const cycleStr = cyclePath.join(' -> ');
        if (!reportedCycles.has(cycleStr)) {
          reportedCycles.add(cycleStr);
          reportWarning(
            AvenxErrorCodes.COMPILER_CIRCULAR_DEPENDENCY,
            new BuildError(AvenxErrorCodes.COMPILER_CIRCULAR_DEPENDENCY, cycleStr),
            this.config,
          );
        }
        return;
      }

      visiting.set(className, stack.length);
      stack.push(className);

      const deps = graph.get(className) || [];
      for (const dep of deps) {
        dfs(dep, stack);
      }

      stack.pop();
      visiting.delete(className);
      visited.add(className);
      orderedClasses.push(className);
    };

    classNameMap.forEach((_, className) => {
      if (!visited.has(className)) {
        dfs(className);
      }
    });

    orderedClasses.forEach((className) => {
      const paths = classNameMap.get(className);
      if (!paths || paths.length === 0) return;
      const fullPath = paths[0];
      logger.info(`[Compiling] ${path.basename(fullPath)}`);
      const compiled = this.componentParser.parse(fullPath);
      if (virtualModules) {
        virtualModules.set(path.resolve(fullPath), this.wrapUnit(fullPath, compiled));
      }
    });
  }

  /**
   * Frames a compiled class as an ES module.
   * @param {string} filePath - The unit's source path.
   * @param {string} compiled - The class declaration the parser produced.
   * @returns {string} The module source.
   * @private
   */
  wrapUnit(filePath, compiled) {
    const meta = this.componentParser.moduleMeta.get(path.resolve(filePath));
    if (!meta) {
      throw new BuildError(
        AvenxErrorCodes.COMPILER_MODULE_UNREADABLE,
        path.relative(this.rootDir, filePath),
        'the compiler produced no module information for this unit',
      );
    }
    return componentModule({
      className: meta.className,
      body: compiled,
      isPage: meta.isPage,
      imports: meta.imports,
      bridgeBindings: meta.bridgeBindings,
    });
  }

  /**
   * Compiles every page in `src/pages` into an ES module.
   *
   * Pages are the one unit a developer never imports: they are discovered by
   * directory and routed by name, so the entry module imports and registers
   * them on their behalf. That is the same contract as before -- what changed
   * is that the registration now names an imported binding rather than a
   * class that happened to be in scope because it had been concatenated above.
   * @param {Map<string, string>} [virtualModules] - Generated module table to fill.
   * @param {Array<object>} [registrations] - Units to import and register.
   * @returns {void}
   * @private
   */
  processPages(virtualModules = null, registrations = null) {
    const pageDir = path.join(this.srcDir, 'pages');

    const scan = (dir) => {
      if (!fs.existsSync(dir)) return;

      fs.readdirSync(dir).forEach((file) => {
        const fullPath = path.join(dir, file);

        if (fs.statSync(fullPath).isDirectory()) {
          scan(fullPath);
          return;
        }
        if (!file.endsWith('.page.js')) return;

        logger.info(`[Compiling Page] ${file}`);

        const name = path
          .basename(file, '.page.js')
          .split(/[-_]/)
          .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
          .join('');

        const compiled = this.componentParser.parse(fullPath, 'page');
        if (virtualModules) {
          virtualModules.set(path.resolve(fullPath), this.wrapUnit(fullPath, compiled));
        }
        if (registrations) {
          registrations.push({ name, file: path.resolve(fullPath), kind: 'page' });
        }
      });
    };

    scan(pageDir);
  }

  /**
   * Compiles a single component file.
   * @param {string} filePath
   * @returns {string}
   */
  compileComponent(filePath) {
    return this.componentParser.parse(filePath);
  }

  /**
   * Compiles a single page file.
   * @param {string} filePath
   * @returns {string}
   */
  compilePage(filePath) {
    return this.componentParser.parse(filePath, 'page');
  }

}

export default AvenxCompiler;