// mtsc — the single entry point of this toolchain.
//
// It is two things at once, and the dispatch in `main` is where they
// meet:
//
//   * a TypeScript -> JavaScript compiler whose command line is a
//     SUPERSET of `tsc` / `tsgo` (see `tsc_options.mbt` for the three
//     tiers that claim rests on, and `tsconfig_project.mbt` for `-p`);
//   * four verbs that used to be four separate binaries —
//     `mtsc check` (was `tscheck`), `mtsc conformance` (was `tsacc`),
//     `mtsc bridge` (was `ts2mbt`) and `mtsc pkg` (was `mbt2ts`).
//
// A verb wins over a file of the same name. That is the one place the
// two halves can collide, and resolving it toward the verb is what keeps
// `mtsc check` from depending on whether a directory called `check`
// happens to exist; a source file is spelled `x.ts`, so nothing real is
// shadowed. `mtsc ./check` still names the file.
//
// The compile pipeline itself is unchanged:
//
//   parse → JSX-runtime lowering → (optional) identifier mangling → emit
//
// When neither `--out` nor `--outDir` is given the JS is written to
// stdout. With `--sourceMap`, a v3 map is also written next to the
// output file (`.map`); a `//# sourceMappingURL=` footer is
// appended to the JS so loaders can pick it up. Mangling and source-maps
// are mutually exclusive for now — the source position table doesn't
// survive the rename pass yet.
//
// Every input is type-checked before it is transformed. `mtsc` uses the
// checker's strict entry point so diagnostics match `mtsc check
// --strict`, and never writes JavaScript when the checker finds an
// issue.

///|
async fn mtsc_build_jsx_config(
  opts : MtscOptions,
  entry_path : String,
) -> @transform.JsxConfig {
  let mut cfg = @transform.JsxConfig::default()
  // tsconfig first, then the CLI flags, then (inside the transform) the
  // per-file pragmas — increasing precedence, matching what TypeScript
  // does with the same three sources.
  //
  // mtsc read `paths` out of tsconfig and nothing else, so a preact /
  // solid / vue-JSX application compiled to
  // `import { jsx } from "react/jsx-runtime"` and could not run at all.
  // The `/** @jsxImportSource preact */` pragma was the only form
  // supported, and no application uses it — the setting goes in
  // tsconfig, per package, and in a monorepo it is inherited through
  // `extends` (sprawlens: `jsx` in the base config, `jsxImportSource`
  // in the package's own).
  match @parser.tsconfig_compiler_option(entry_path, "jsx") {
    Some(jsx) =>
      // `preserve` and `react-native` leave JSX in the output, which
      // this pipeline cannot do — it always lowers — so they map to the
      // automatic runtime rather than being honoured.
      match jsx {
        "react" => cfg = cfg.with_runtime("classic")
        "react-jsxdev" => cfg = cfg.with_runtime("automatic").with_dev(true)
        _ => cfg = cfg.with_runtime("automatic")
      }
    None => ()
  }
  match @parser.tsconfig_compiler_option(entry_path, "jsxImportSource") {
    Some(src) => cfg = cfg.with_import_source(src)
    None => ()
  }
  match @parser.tsconfig_compiler_option(entry_path, "jsxFactory") {
    Some(f) => cfg = cfg.with_pragma_jsx(f)
    None => ()
  }
  match @parser.tsconfig_compiler_option(entry_path, "jsxFragmentFactory") {
    Some(f) => cfg = cfg.with_pragma_jsx_frag(f)
    None => ()
  }
  match opts.jsx_runtime {
    Some(r) => cfg = cfg.with_runtime(r)
    None => ()
  }
  match opts.jsx_import_source {
    Some(s) => cfg = cfg.with_import_source(s)
    None => ()
  }
  // `--jsxFactory` / `--jsxFragmentFactory` had no command-line spelling
  // at all: the two settings were read from tsconfig above and from the
  // per-file pragma inside the transform, and the flags `tsc` offers for
  // them fell through to "unknown option". The CLI sits above tsconfig
  // in the same precedence order as the other two.
  match opts.jsx_factory {
    Some(f) => cfg = cfg.with_pragma_jsx(f)
    None => ()
  }
  match opts.jsx_fragment_factory {
    Some(f) => cfg = cfg.with_pragma_jsx_frag(f)
    None => ()
  }
  if opts.jsx_dev {
    cfg = cfg.with_dev(true)
  }
  cfg
}

///|
fn string_to_utf8_bytes(s : String) -> Bytes {
  // MoonBit's `String` is a sequence of UTF-16 code units. Real
  // UTF-8 encoding has to:
  //   * 1 byte for ASCII (< 0x80)
  //   * 2 bytes for U+0080..U+07FF
  //   * 3 bytes for U+0800..U+FFFF (BMP), excluding the surrogate
  //     range
  //   * 4 bytes for U+10000..U+10FFFF (built from a surrogate
  //     pair: high in U+D800..U+DBFF, low in U+DC00..U+DFFF)
  //
  // The previous implementation kept only the low byte of each
  // unit, which truncated everything ≥ U+0080 and produced
  // mojibake for non-ASCII strings ("regulární" -> "regul�rn�").
  let arr : Array[Byte] = []
  let mut i = 0
  while i < s.length() {
    let unit = s[i].to_int()
    if unit < 0x80 {
      arr.push(unit.to_byte())
      i = i + 1
    } else if unit < 0x800 {
      arr.push(((unit >> 6) | 0xC0).to_byte())
      arr.push(((unit & 0x3F) | 0x80).to_byte())
      i = i + 1
    } else if unit >= 0xD800 && unit < 0xDC00 {
      // High surrogate: combine with the next low surrogate.
      let mut done = false
      if i + 1 < s.length() {
        let lo = s[i + 1].to_int()
        if lo >= 0xDC00 && lo < 0xE000 {
          let cp = (((unit - 0xD800) << 10) | (lo - 0xDC00)) + 0x10000
          arr.push(((cp >> 18) | 0xF0).to_byte())
          arr.push((((cp >> 12) & 0x3F) | 0x80).to_byte())
          arr.push((((cp >> 6) & 0x3F) | 0x80).to_byte())
          arr.push(((cp & 0x3F) | 0x80).to_byte())
          i = i + 2
          done = true
        }
      }
      if !done {
        // Unpaired high surrogate → U+FFFD replacement (`EF BF BD`).
        arr.push((0xEF : Int).to_byte())
        arr.push((0xBF : Int).to_byte())
        arr.push((0xBD : Int).to_byte())
        i = i + 1
      }
    } else if unit >= 0xDC00 && unit < 0xE000 {
      // Stray low surrogate → U+FFFD.
      arr.push((0xEF : Int).to_byte())
      arr.push((0xBF : Int).to_byte())
      arr.push((0xBD : Int).to_byte())
      i = i + 1
    } else {
      arr.push(((unit >> 12) | 0xE0).to_byte())
      arr.push((((unit >> 6) & 0x3F) | 0x80).to_byte())
      arr.push(((unit & 0x3F) | 0x80).to_byte())
      i = i + 1
    }
  }
  Bytes::from_array(arr)
}

///|
async fn write_text(path : String, content : String) -> Bool {
  let _ = @fs.write_file(path, string_to_utf8_bytes(content), create=0o644) catch {
    e => {
      println("write error \{path}: \{e}")
      return false
    }
  }
  true
}

///|
fn mtsc_dirname(path : String) -> String {
  match path.rev_find("/") {
    Some(i) => path[0:i].to_owned()
    None => ""
  }
}

///|
fn mtsc_basename(path : String) -> String {
  match path.rev_find("/") {
    Some(i) => path[i + 1:].to_owned()
    None => path
  }
}

///|
/// A specifier the loader resolves against the filesystem itself, rather
/// than handing to package resolution: relative (`./x`, `../x`, `.`,
/// `..`) or rooted (`/x`).
///
/// One predicate for all four test sites in the graph walk. The import
/// loop and the re-export loop are mirror images of each other and each
/// tests this twice — once to decide whether a type-only edge is worth
/// following, once to pick the resolve branch — so an inlined test is
/// four copies that have to agree. `.` and `..` were missing from all
/// four; see `@transform.is_relative_specifier`.
fn mtsc_is_local_specifier(spec : String) -> Bool {
  @transform.is_relative_specifier(spec) || spec.has_prefix("/")
}

///|
fn mtsc_join_path(dir : String, child : String) -> String {
  if dir == "" {
    child
  } else if dir.has_suffix("/") {
    dir + child
  } else {
    dir + "/" + child
  }
}

///|
fn mtsc_normalize(path : String) -> String {
  let parts : Array[String] = []
  for part in path.split("/") {
    let p = part.to_owned()
    if p == "" || p == "." {
      continue
    }
    if p == ".." {
      if parts.length() > 0 && parts[parts.length() - 1] != ".." {
        let _ = parts.pop()
      } else {
        parts.push("..")
      }
      continue
    }
    parts.push(p)
  }
  let leading = if path.has_prefix("/") { "/" } else { "" }
  leading + parts.join("/")
}

///|
/// An empty `TsModule`, for the paths that need a value but no
/// declarations — a type-side parse that failed, or one that was
/// skipped because nothing downstream reads it.
fn mtsc_empty_module() -> @ast.TsModule {
  {
    funcs: [],
    interfaces: [],
    type_aliases: [],
    imports: [],
    values: [],
    classes: [],
    local_classes: [],
    namespaces: [],
    enums: [],
    module_augmentations: [],
    strict_property_initialization: true,
    no_implicit_any: true,
    strict_null_checks: true,
    use_define_for_class_fields: false,
    deprecated_compiler_options: [],
    parameter_property_misuses: [],
    strict_mode_misuses: [],
    invalid_decorator_uses: [],
    grammar_misuses: [],
    imported_binding_names: [],
    import_module_specs: [],
    class_base_type_args: [],
    param_optional_initializer_misuses: [],
    interface_member_modifier_misuses: [],
    class_dup_diagnostics: [],
    top_level_stmts: [],
  }
}

///|
/// Does this path name a file the JavaScript pipeline cannot parse and
/// has no business parsing — a stylesheet, an image, a font, a media
/// file? Bundlers resolve these through loaders; a JS-only bundle has
/// nothing to emit for them.
///
/// An extension list rather than "anything not .ts/.tsx/.js": a resolved
/// path with an unfamiliar extension is more likely a module the
/// resolver mis-resolved than an asset, and silently emptying that would
/// turn a resolution bug into missing code rather than an error.
fn is_non_js_asset_path(path : String) -> Bool {
  for
    ext in [
      ".css", ".scss", ".sass", ".less", ".styl", ".svg", ".png", ".jpg", ".jpeg",
      ".gif", ".webp", ".avif", ".ico", ".bmp", ".woff", ".woff2", ".ttf", ".otf",
      ".eot", ".mp3", ".mp4", ".wav", ".ogg", ".webm", ".pdf", ".wasm",
    ] {
    if path.has_suffix(ext) {
      return true
    }
  }
  false
}

///|
/// Is this asset path a STYLESHEET whose import is going to be read as a
/// value?
///
/// The caller only has the resolved path at this point, not the import
/// declaration that reached it, so this answers the narrower question it
/// can: is it a stylesheet at all. The side-effect form is by far the
/// common one and is handled correctly, so warning on every stylesheet
/// would be noise — which is why the caller pairs this with the
/// value-form check it can make.
fn is_stylesheet_path(path : String) -> Bool {
  for ext in [".css", ".scss", ".sass", ".less", ".styl"] {
    if path.has_suffix(ext) {
      return true
    }
  }
  false
}

///|
/// Walk imports starting from `entry_path` and read every reachable
/// file from disk into `files`. Only relative imports (`./x`, `../y`)
/// and rooted paths are followed — bare specifiers (`react`, `@scope/x`)
/// are left to the bundler to emit verbatim.
async fn mtsc_load_bundle_files(
  entry_path : String,
  files : Map[String, String],
  aliases : Map[String, String],
  externals : Map[String, Bool],
  type_props : Map[String, Bool],
  // Whether anything downstream reads `type_props`. Only
  // `--reserve-typed-props` does, and harvesting it costs a full second
  // parse of every file in the graph — 0.9 s of the 2.1 s read+parse
  // phase on 9 MB of `typescript.js`. Skip it when nobody asked.
  want_type_props : Bool,
  // `[tokenize, parse, read+decode, resolve]` micros accumulated across
  // every file, so `--timing` can separate the lexer from the recursive
  // descent and both from the filesystem. Reported as four rows because
  // the single "read + parse files" row hid the actual hot spot: path
  // resolution, not parsing, was 42-60% of a multi-file compile.
  parse_split : Array[Double],
  // Every block this walk parses, keyed the way `files` is, so the
  // bundler can reuse them instead of parsing the same sources again.
  // The walk has to parse each file to find its imports, and the
  // bundler has to parse each file to build the module graph — the same
  // work, twice, for what is 78-94% of a bundle's wall time.
  parsed : Map[String, @ast.TsModuleBlock],
  // Import / re-export edges discovered along the way, in the shape the
  // graph checker wants: resolution is the CLI's job, and the checker
  // needs the edges to resolve a type declared in another module.
  edges : Array[@mtsc_core.MtscModuleEdge],
) -> Result[Unit, String] {
  // Sequential DFS over the import graph. moonbitlang/async runs on
  // a cooperative single-threaded scheduler, so spawning per-file
  // tasks adds task-group overhead without overlapping any real
  // work — the parser itself stays the dominant CPU cost.
  let mut tokenize_micros = 0.0
  let mut parse_micros = 0.0
  let mut read_micros = 0.0
  let mut resolve_micros = 0.0
  // The sibling `.d.ts` scan, separately. It was inside the
  // "module graph walk" residual, and it is not a walk at all: next to
  // `node_modules/typescript/lib/typescript.js` there are 98 `.d.ts`
  // files totalling 3.2 MB, and this read and fully parsed every one of
  // them. That is what a residual hides.
  let mut ambient_micros = 0.0
  // The import / re-export edge walk, which is what the "module graph
  // walk" residual is actually named after. On a 95-file target it was
  // 310 ms with no ambient `.d.ts` in sight, so the label had to be
  // checked rather than believed.
  let mut edge_micros = 0.0
  // Bare-specifier resolution (`react`, `roughjs/bin/math`), separately
  // from the relative path math above it. Both are "module resolution",
  // and they are not the same cost: one is arithmetic over a cached
  // directory listing, the other walks `node_modules` and reads
  // `package.json` `exports` / `typesVersions`. Once `cli: resolve
  // module paths` became the largest row on a 95-file target there was
  // no way to act on it without knowing which half it was.
  let mut bare_micros = 0.0
  // Time every `mtsc_resolve_existing` call, cache hits included, so the
  // row measures what resolution costs the compile rather than what it
  // costs on a miss.
  async fn timed_resolve(base : String) -> String {
    let started = @bench.monotonic_clock_start()
    let r = mtsc_resolve_existing(base)
    resolve_micros = resolve_micros + @bench.monotonic_clock_end(started)
    r
  }

  let queue : Array[String] = [entry_path]
  while queue.length() > 0 {
    let disk_path = queue[queue.length() - 1]
    let _ = queue.pop()
    if files.contains(disk_path) {
      continue
    }
    let resolved = timed_resolve(disk_path)
    // A relative import that names no file on disk is not a reason to
    // fail the compile. It is what a build-generated module looks like
    // from a source checkout: TypeScript's own `checker.ts` imports
    // `./_namespaces/ts.performance.js`, which its build produces and
    // the repository does not carry. Treat it the way a bare specifier
    // is treated — leave the import verbatim and move on. The entry
    // file itself is exempt: nothing to compile if that is missing.
    if disk_path != entry_path && !mtsc_file_exists(resolved) {
      println(
        "mtsc: unresolved import \{disk_path} — left as an external import",
      )
      continue
    }
    // Already loaded under its real path? Then this is the same module
    // reached by a different spelling, and there is nothing to do.
    //
    // The guard above this loop keys on the SPECIFIER, and for the two
    // spellings that resolve to one file that is not the same question.
    // `./util` -> `util.ts` gets an alias entry below, so the specifier
    // guard catches the second visit. `./util.js` -> `util.ts` cannot:
    // the alias is only written when resolution APPENDED an extension,
    // and here it replaced one. So nothing deduplicated the
    // `.js`-specifier style that TypeScript-with-NodeNext sources use
    // everywhere, and each repeat visit re-read the file, re-parsed it,
    // and re-pushed its own imports — which repeat in turn.
    //
    // On a diamond-shaped graph that is 2^depth. Measured on a
    // synthetic chain where every module imports both modules of the
    // next level: with `.js` specifiers, depth 4/6/8/10/12 took
    // 9/19/61/217/853 ms, roughly 4x per two levels; with extension-less
    // specifiers the same graphs were flat at 8-11 ms. zod is that shape
    // — 133 files, `from "../core/util.js"` 65 times — and had not
    // finished PARSING after eighteen minutes.
    if files.contains(resolved) {
      // Skipping the work is the fix; the alias still has to be written,
      // or the bundler cannot resolve the specifier this importer wrote.
      // Same condition as the main path below, for the same reason.
      if disk_path != resolved &&
        resolved.has_prefix(disk_path + ".") &&
        !files.contains(disk_path) {
        match files.get(resolved) {
          Some(src) => files[disk_path] = src
          None => ()
        }
        match parsed.get(resolved) {
          Some(mb) => parsed[disk_path] = mb
          None => ()
        }
      }
      continue
    }
    let read_started = @bench.monotonic_clock_start()
    // Decide BEFORE reading. A `.woff2` is binary and decoding it as
    // UTF-8 fails outright (`utf8.Malformed`), so an asset has to be
    // recognised by its path, not by its contents — and skipping the
    // read also keeps megabytes of fonts and images out of the compile.
    let asset = is_non_js_asset_path(resolved)
    let raw_source = if asset {
      ""
    } else {
      @fs.read_file(resolved).text() catch {
        e => return Err("read error \{resolved}: \{e}")
      }
    }
    read_micros = read_micros + @bench.monotonic_clock_end(read_started)
    // A `.json` import is a module whose default export is the parsed
    // document. Handing the file to the TypeScript parser instead is a
    // syntax error on the first key — `Expected Semicolon, got Colon` —
    // and it takes the whole compile with it. Excalidraw's `i18n.ts`
    // does `import percentages from "./locales/percentages.json"`, which
    // is enough to stop its 1 MB element package from bundling at all.
    //
    // JSON is a subset of the expression grammar, so the document can
    // become a module by wrapping it rather than by teaching the parser
    // a second grammar: `export default ;` parses as the
    // object / array / scalar literal it already is.
    //
    // Default export only. That is what an ESM JSON module offers, and
    // it is what every import of one in the corpus uses;
    // `resolveJsonModule`'s named imports of top-level keys would need
    // one `export const` per key and are not synthesized here.
    //
    // One semantic difference is worth naming: a `"__proto__"` key in an
    // object literal sets the prototype instead of becoming an own
    // property, where `JSON.parse` makes it an own property. A document
    // with that key is mis-modelled.
    // An asset import is not JavaScript and has no JavaScript to
    // contribute. `import "./ContextMenu.scss"` is a side effect a CSS
    // pipeline resolves, and handing the stylesheet to the TypeScript
    // parser is `Expected type, got Star` on the first `*` selector.
    // Excalidraw reaches one from its element package and the whole
    // compile stops there.
    //
    // Synthesize an empty module instead. The side-effect form then
    // contributes nothing, which is correct for a JS-only bundle.
    //
    // The VALUE form (`import styles from "./x.module.css"`) still gets
    // `{}`, and that is not a fix: it is not the class-name map a
    // CSS-modules pipeline would build, so a bundle that reads
    // `styles.foo` sees `undefined`. The import site reports it — the
    // warning lives at the enqueue below, which is where the import
    // DECLARATION is in scope and the value form can be told from the
    // side-effect one — and the reason for not synthesizing something
    // is worth stating.
    //
    // Scanning the stylesheet for class selectors and handing back an
    // identity map (`{ foo: "foo" }`) was the obvious alternative. It
    // trades a value that fails LOUDLY — `undefined.length` throws,
    // `${styles.foo}` renders "undefined" — for one that fails SILENTLY:
    // a plausible class name that matches no rule, because mtsc emits no
    // CSS either. Unstyled UI and no error is the worse outcome.
    //
    // And there is no evidence it matters. All 76 of Excalidraw's
    // stylesheet imports are the side-effect form, which is handled
    // correctly; the corpus has no value-form CSS import at all. So the
    // honest move is to say what cannot be done rather than to guess,
    // and to leave the guess for a caller who actually has a pipeline.
    let source = if asset {
      "export default {};\n"
    } else if resolved.has_suffix(".json") {
      "export default " + raw_source + ";\n"
    } else {
      raw_source
    }
    files[resolved] = source
    // The extension-less form is also keyed, so the bundler can look up
    // the specifier a module actually spelled (`./sub` -> `/dir/sub`).
    // But ONLY when resolution just appended an extension. When it went
    // through `/index.ts`, the alias names a DIRECTORY, and a
    // module keyed by a directory path resolves its own relative
    // imports one level too high: `src/router/trie-router` +
    // `./router` becomes `src/router/router`. Hono hits this on its
    // first router import.
    if disk_path != resolved &&
      resolved.has_prefix(disk_path + ".") &&
      !files.contains(disk_path) {
      files[disk_path] = source
    }
    // Split so `--timing` says which half of "parse" is which:
    // tokenizing builds the whole token array up front, walking it is a
    // separate cost, and the two want different fixes.
    let tokenize_started = @bench.monotonic_clock_start()
    let parser = @parser.Parser::from_source(source)
    tokenize_micros = tokenize_micros +
      @bench.monotonic_clock_end(tokenize_started)
    let parse_started = @bench.monotonic_clock_start()
    let mb = parser.parse_module_block() catch {
      e => return Err("parse \{resolved}: \{e}")
    }
    parse_micros = parse_micros + @bench.monotonic_clock_end(parse_started)
    // Keyed exactly like `files` above, including the extension-less
    // alias, because the bundler looks a module up by whichever form
    // the importing specifier spelled.
    parsed[resolved] = mb
    if disk_path != resolved && resolved.has_prefix(disk_path + ".") {
      parsed[disk_path] = mb
    }
    // Second-pass parse for the TYPE side. The block parser above
    // only sees runtime statements; interfaces / type aliases /
    // classes live on `TsModule`, so we run a second parse to
    // harvest the property names declared in those type
    // declarations. They get added to the bundler's reserved-prop
    // set so the property mangler never renames a name that's
    // part of any module's public type surface.
    let module_ = if want_type_props {
      @parser.Parser::from_source(source).parse_module() catch {
        _ => mtsc_empty_module()
      }
    } else {
      mtsc_empty_module()
    }
    if want_type_props {
      for n in @transform.type_declared_property_names(module_) {
        type_props[n] = true
      }
    }
    // Bracketed, minus the resolution accumulated inside it. The first
    // version of this span was a plain wall-clock bracket while
    // `resolve_micros` accumulated sub-spans INSIDE it, so the two
    // OVERLAPPED: the edge walk read 358 ms and looked like the largest
    // item on a 95-file target, when almost all of it was the module
    // resolution already reported on its own row. An overlapping span
    // is not a small error — it is a wrong number that reads like a
    // finding, and it sent this investigation at the wrong function.
    // Every row here is now disjoint, so they sum.
    let edges_started = @bench.monotonic_clock_start()
    let resolve_before_edges = resolve_micros + bare_micros
    for imp in mb.imports {
      // A type-only import contributes no runtime code, so the bundler
      // skips it — but the CHECKER needs the module that declares the
      // type, or `import type { Loc } from "./types"` leaves `Loc`
      // unresolvable and identical shapes read as mismatches. Load and
      // edge relative type-only imports; a bare one would drag a whole
      // package's declarations in, so those stay skipped.
      let spec = imp.module_spec
      if imp.type_only && !mtsc_is_local_specifier(spec) {
        continue
      }
      if mtsc_is_local_specifier(spec) {
        // A stylesheet imported for its VALUE — `import styles from
        // "./x.module.css"` — is the one asset form mtsc cannot model:
        // the synthesized module hands back `{}`, so every `styles.foo`
        // is `undefined` and the failure is silent. Say so here rather
        // than guessing a class-name map; the note at the asset stub
        // above says why guessing is worse.
        //
        // The side-effect form (`import "./x.scss"`) is correct and
        // stays silent — all 76 of Excalidraw's stylesheet imports are
        // that form, which is why this cannot warn on every stylesheet.
        if is_stylesheet_path(spec) &&
          (
            imp.default_binding is Some(_) ||
            imp.namespace_binding is Some(_) ||
            imp.named_bindings.length() > 0
          ) {
          println(
            "mtsc: \{resolved} imports `\{spec}` for its value, but mtsc has no CSS pipeline — the import yields `{}`, so every class-name lookup on it is `undefined`",
          )
        }
        let combined = if spec.has_prefix("/") {
          mtsc_normalize(spec)
        } else {
          mtsc_normalize(mtsc_join_path(mtsc_dirname(resolved), spec))
        }
        edges.push({
          importerPath: resolved,
          moduleSpecifier: spec,
          targetPath: timed_resolve(combined),
        })
        queue.push(combined)
        continue
      }
      if externals.contains(spec) {
        continue
      }
      if aliases.contains(spec) {
        continue
      }
      let bare_started = @bench.monotonic_clock_start()
      let bare = @parser.resolve_runtime_module_specifier(resolved, spec)
      bare_micros = bare_micros + @bench.monotonic_clock_end(bare_started)
      match bare {
        Some(found) => {
          aliases[spec] = found
          edges.push({
            importerPath: resolved,
            moduleSpecifier: spec,
            targetPath: found,
          })
          queue.push(found)
        }
        None => ()
      }
    }
    // Re-exports (`export * from "./a"`, `export { X } from "./a"`)
    // also pull `./a` into the bundle. Mirror the import loop.
    for re in mb.reexports {
      // Same reasoning as the type-only import above.
      let spec = re.module_spec
      if re.type_only && !mtsc_is_local_specifier(spec) {
        continue
      }
      if mtsc_is_local_specifier(spec) {
        let combined = if spec.has_prefix("/") {
          mtsc_normalize(spec)
        } else {
          mtsc_normalize(mtsc_join_path(mtsc_dirname(resolved), spec))
        }
        edges.push({
          importerPath: resolved,
          moduleSpecifier: spec,
          targetPath: timed_resolve(combined),
        })
        queue.push(combined)
        continue
      }
      if externals.contains(spec) {
        continue
      }
      if aliases.contains(spec) {
        continue
      }
      let bare_started = @bench.monotonic_clock_start()
      let bare = @parser.resolve_runtime_module_specifier(resolved, spec)
      bare_micros = bare_micros + @bench.monotonic_clock_end(bare_started)
      match bare {
        Some(found) => {
          aliases[spec] = found
          edges.push({
            importerPath: resolved,
            moduleSpecifier: spec,
            targetPath: found,
          })
          queue.push(found)
        }
        None => ()
      }
    }
    edge_micros = edge_micros +
      @bench.monotonic_clock_end(edges_started) -
      (resolve_micros + bare_micros - resolve_before_edges)
  }
  // Ambient declaration files (`declare const MyGlobal: …`) belong to the
  // program even though nothing imports them — that is the whole point of
  // an ambient declaration. `tsc` picks them up through tsconfig's
  // `include`; with no tsconfig to read, take the `.d.ts` files sitting in
  // the entry's own directory, which is where the `env.d.ts` /
  // `globals.d.ts` convention puts them.
  let ambient_started = @bench.monotonic_clock_start()
  for ambient in mtsc_sibling_declaration_files(entry_path) {
    if files.contains(ambient) {
      continue
    }
    let source = @fs.read_file(ambient).text() catch {
      // A directory read that fails is not fatal here: the file is
      // supplementary, not part of the module graph.
      _ => continue
    }
    files[ambient] = source
    // The PARSE is only here to harvest `type_props`, and only
    // `--reserve-typed-props` reads that — the same gate the main loop
    // already applies to its own second parse, which this scan never
    // got. Next to `typescript.js` that is 98 files and 3.2 MB parsed
    // to fill a map nobody was going to look at. Fourth time in this
    // pipeline that a condition was applied in one place and not in the
    // second consumer.
    //
    // The READ stays unconditional: `files[ambient]` is what puts an
    // ambient declaration into the program for the checker, which is
    // the point of the scan (`case03`).
    if want_type_props {
      let parsed = try
        @parser.Parser::from_source(source).parse_module()
      catch {
        e => Err(e)
      } noraise {
        m => Ok(m)
      }
      match parsed {
        Ok(module_) =>
          for n in @transform.type_declared_property_names(module_) {
            type_props[n] = true
          }
        Err(_) => ()
      }
    }
  }
  ambient_micros = ambient_micros + @bench.monotonic_clock_end(ambient_started)
  parse_split.push(tokenize_micros)
  parse_split.push(parse_micros)
  parse_split.push(read_micros)
  parse_split.push(resolve_micros)
  parse_split.push(ambient_micros)
  parse_split.push(edge_micros)
  parse_split.push(bare_micros)
  Ok(())
}

///|
/// `.d.ts` files in the same directory as `entry_path`, sorted for a
/// deterministic program. Nested directories are not scanned: an ambient
/// file deeper in the tree belongs to whatever entry sits next to it.
async fn mtsc_sibling_declaration_files(entry_path : String) -> Array[String] {
  let dir = mtsc_dirname(entry_path)
  let names = @fs.readdir(dir, sort=true) catch { _ => return [] }
  let out : Array[String] = []
  for name in names {
    if name.has_suffix(".d.ts") {
      out.push(mtsc_normalize(mtsc_join_path(dir, name)))
    }
  }
  out
}

///|
/// The TypeScript files a JS-spelled specifier may actually name.
/// `./x.js` -> `./x.ts`, `./x.tsx`, `./x.d.ts`; `./x.mjs` -> `./x.mts`;
/// `./x.cjs` -> `./x.cts`. Empty when the path does not end in a JS
/// extension, so the ordinary extension search runs unchanged.
fn mtsc_ts_equivalents(base : String) -> Array[String] {
  for
    pair in [
      (".js", [".ts", ".tsx", ".d.ts"]),
      (".mjs", [".mts", ".d.mts"]),
      (".cjs", [".cts", ".d.cts"]),
    ] {
    if base.has_suffix(pair.0) {
      let stem = base[0:base.length() - pair.0.length()].to_owned()
      let out : Array[String] = []
      for ext in pair.1 {
        out.push(stem + ext)
      }
      return out
    }
  }
  []
}

///|
/// Resolution caches. A compile is one process over a snapshot of the
/// tree, so nothing here can go stale within a run.
///
/// They exist because module resolution, not parsing, was the largest
/// phase on every multi-file target once `--timing` could separate it
/// from reading: 235 ms of valibot's 394 ms (59.6%) and 30 ms of hono's
/// 71 ms (42.4%), against 12-18% for tokenize plus recursive descent
/// combined. The cause is arithmetic — `mtsc_resolve_existing` probed up
/// to thirteen candidate paths per import, and every probe was an
/// `@fs.exists` plus an `@fs.kind`, so one import cost ~26 syscalls to
/// find one file.
///
/// `mtsc_dir_entries` turns that around: list a directory once, and a
/// candidate that does not exist is then a map lookup rather than a
/// syscall. Only a candidate the listing admits costs an `@fs.kind`, and
/// that is at most one per import.
let mtsc_dir_entries : Map[String, Map[String, Bool]] = Map([])

///|
/// `@fs.kind(path) is Regular`, memoized. Distinct from the listing:
/// the listing says a *name* is there, this says it is a file and not a
/// directory.
let mtsc_regular_files : Map[String, Bool] = Map([])

///|
/// `mtsc_resolve_existing` results. The loader resolves the same path
/// twice per edge — once to record the edge, once when the queue reaches
/// it — and a module imported from n places is resolved n times.
let mtsc_resolved_paths : Map[String, String] = Map([])

///|
/// Drop everything the three caches above hold.
///
/// Their stated premise is that "a compile is one process over a
/// snapshot of the tree, so nothing here can go stale within a run", and
/// `--watch` is the one caller for which that is false: a rebuild exists
/// because the tree changed. Without this, a file created after the
/// first pass stays cached as absent and the rebuild cannot see it.
fn mtsc_clear_resolution_caches() -> Unit {
  mtsc_dir_entries.clear()
  mtsc_regular_files.clear()
  mtsc_resolved_paths.clear()
}

///|
/// The names directly inside `dir`, read once. A directory that cannot
/// be read caches as empty: that is the same answer every candidate
/// under it would have got, and it stops a missing directory from being
/// re-probed per candidate.
async fn mtsc_dir_listing(dir : String) -> Map[String, Bool] {
  let key = if dir == "" { "." } else { dir }
  match mtsc_dir_entries.get(key) {
    Some(m) => m
    None => {
      let m : Map[String, Bool] = Map([])
      let names = @fs.readdir(key) catch { _ => [] }
      for n in names {
        m[n] = true
      }
      mtsc_dir_entries[key] = m
      m
    }
  }
}

///|
async fn mtsc_resolve_existing(base : String) -> String {
  match mtsc_resolved_paths.get(base) {
    Some(r) => return r
    None => ()
  }
  let r = mtsc_resolve_existing_uncached(base)
  mtsc_resolved_paths[base] = r
  r
}

///|
async fn mtsc_resolve_existing_uncached(base : String) -> String {
  if mtsc_file_exists(base) {
    return base
  }
  // A specifier that spells `.js` may name a `.ts` file. That is not a
  // quirk — it is what TypeScript's `node16` / `nodenext` / `bundler`
  // resolution prescribes, so a modern TS codebase writes
  // `import … from "./external.js"` next to `external.ts`. Without this
  // rule the whole class of TypeScript-source targets is unreachable:
  // zod, and TypeScript's own compiler (`./_namespaces/ts.js`), both
  // fail on their first import.
  for cand in mtsc_ts_equivalents(base) {
    if mtsc_file_exists(cand) {
      return cand
    }
  }
  // `.d.ts` is in the list because a declaration-only module is a
  // legitimate target of `import type { X } from "./types"`.
  let exts = [".ts", ".tsx", ".d.ts", ".js", ".mjs", ".jsx"]
  for ext in exts {
    let cand = base + ext
    if mtsc_file_exists(cand) {
      return cand
    }
  }
  for ext in exts {
    let cand = base + "/index" + ext
    if mtsc_file_exists(cand) {
      return cand
    }
  }
  base
}

///|
/// A path that names a readable FILE. `@fs.exists` also answers `true`
/// for a directory, and a directory is not a module: `import "./client"`
/// next to a `client/` directory used to resolve to the directory
/// itself, and the read then failed with "Is a directory" instead of
/// falling through to `client/index.ts`.
async fn mtsc_file_exists(path : String) -> Bool {
  match mtsc_regular_files.get(path) {
    Some(v) => return v
    None => ()
  }
  let base = mtsc_basename(path)
  // `.` and `..` are not in a listing (`readdir` drops the special
  // entries) and are directories anyway, so answer without one. An empty
  // base means a trailing slash, which likewise names a directory.
  if base == "" || base == "." || base == ".." {
    mtsc_regular_files[path] = false
    return false
  }
  // The parent's listing settles non-existence for free. Only a name
  // that IS there is worth a `kind` syscall.
  if !mtsc_dir_listing(mtsc_dirname(path)).contains(base) {
    mtsc_regular_files[path] = false
    return false
  }
  let v = (@fs.kind(path) catch {
      _ => {
        mtsc_regular_files[path] = false
        return false
      }
    })
    is Regular
  mtsc_regular_files[path] = v
  v
}

///|
/// Compatibility wrapper for the command's focused tests. The checker itself
/// lives in `mizchi/ts/mtsc` so the JavaScript module and CLI use one strict
/// implementation.
fn mtsc_collect_type_issues(
  source : String,
  allow_jsx : Bool,
) -> Result[Array[@mtsc_core.MtscTypeIssue], String] {
  @mtsc_core.collect_type_issues(source, allow_jsx)
}

///|
/// Print checker diagnostics for one input and return whether it is clean.
fn mtsc_typecheck_source(source : String, path : String) -> Bool {
  let issues = match mtsc_collect_type_issues(source, path.has_suffix(".tsx")) {
    Ok(issues) => issues
    Err(e) => {
      println("mtsc: parse error in \{path}: \{e}")
      return false
    }
  }
  if issues.length() == 0 {
    return true
  }
  for issue in issues {
    println("mtsc: type error in \{path} [\{issue.path}]: \{issue.message}")
  }
  false
}

///|
/// Print checker diagnostics for a whole resolved module graph and
/// return whether it is clean.
///
/// Only files that look like real modules are handed over: the loader
/// keys `files` by both the resolved path and the extension-less form a
/// specifier spelled (`./sub` -> `/dir/sub`), and passing the same
/// source twice would double every diagnostic.
fn mtsc_typecheck_graph(
  files : Map[String, String],
  edges : Array[@mtsc_core.MtscModuleEdge],
) -> Bool {
  let modules : Array[@mtsc_core.MtscModuleSource] = []
  for path in files.keys() {
    if !mtsc_is_module_path(path) {
      continue
    }
    match files.get(path) {
      Some(source) =>
        modules.push({
          path,
          source,
          allowJsx: path.has_suffix(".tsx") || path.has_suffix(".jsx"),
        })
      None => ()
    }
  }
  let reported : Array[@mtsc_core.MtscGraphIssue] = []
  for issue in @mtsc_core.collect_module_graph_issues({ modules, edges, }) {
    // Import-resolution diagnostics are dropped here. They are part of
    // the stricter contract the Vite integration wants, but this CLI
    // cannot tell a type-only use of a value-form import
    // (`import { LocalObj } from "./types"` where `LocalObj` is a type
    // alias — legal TypeScript) from a genuinely missing export, and
    // reporting the first would reject valid programs.
    if issue.path == "import" {
      continue
    }
    reported.push(issue)
  }
  if reported.length() == 0 {
    return true
  }
  for issue in reported {
    println(
      "mtsc: type error in \{issue.modulePath} [\{issue.path}]: \{issue.message}",
    )
  }
  false
}

///|
/// True for a path that names a source file we should type-check.
fn mtsc_is_module_path(path : String) -> Bool {
  path.has_suffix(".ts") ||
  path.has_suffix(".tsx") ||
  path.has_suffix(".mts") ||
  path.has_suffix(".js") ||
  path.has_suffix(".mjs") ||
  path.has_suffix(".jsx")
}

///|
#cfg(any(target="native", target="llvm"))
extern "c" fn mtsc_native_exit(code : Int) = "exit"

///|
#cfg(target="js")
extern "js" fn mtsc_js_try_exit(code : Int) -> Bool =
  #| function(code) {
  #|   if (typeof process !== "undefined" && typeof process.exit === "function") {
  #|     process.exit(code);
  #|     return true;
  #|   }
  #|   return false;
  #| }

///|
#cfg(target="wasm")
fn mtsc_wasm_exit(code : Int) -> Unit = "wasi_snapshot_preview1" "proc_exit"

///|
#cfg(target="wasm-gc")
fn mtsc_wasm_gc_exit(code : Int) -> Unit = "__moonbit_sys_unstable" "exit"

///|
/// Leave the process with `code`. See `MTSC_EXIT_*` in
/// `tsc_options.mbt` for what each value means to a caller.
///
/// This used to be `mtsc_exit_type_error()`, hard-wired to 1 and called
/// from inside the compile. Both properties had to go: `tsc`
/// distinguishes "diagnostics, nothing written" (1) from "diagnostics,
/// written anyway" (2), and a compile that exits the process cannot be
/// re-run, which is the whole of `--watch`.
#cfg(any(target="native", target="llvm"))
fn mtsc_exit(code : Int) -> Unit {
  mtsc_native_exit(code)
}

///|
#cfg(target="js")
fn mtsc_exit(code : Int) -> Unit {
  if !mtsc_js_try_exit(code) {
    panic()
  }
}

///|
#cfg(target="wasm")
fn mtsc_exit(code : Int) -> Unit {
  mtsc_wasm_exit(code)
}

///|
#cfg(target="wasm-gc")
fn mtsc_exit(code : Int) -> Unit {
  mtsc_wasm_gc_exit(code)
}

///|
/// `fflush(NULL)`, which flushes every open output stream.
///
/// The argument is declared `Int64` and passed `0L` because `fflush`
/// takes a `FILE*` and MoonBit has no pointer type to hand it. On both
/// ABIs this targets (x86-64 SysV and aarch64 AAPCS) a 64-bit integer
/// and a pointer are passed in the same register, so `0L` arrives as
/// `NULL` — the same assumption the `exit` binding above already makes
/// about an `Int` argument.
#cfg(any(target="native", target="llvm"))
extern "c" fn mtsc_native_fflush(stream : Int64) -> Int = "fflush"

///|
/// Push anything buffered in stdout out to the terminal or pipe.
///
/// Only `--watch` needs it, and it needs it badly: C stdio block-buffers
/// stdout when it is not a terminal, and a watcher never exits, so
/// `mtsc --watch > build.log` produced an EMPTY log while it was working
/// correctly — every line appeared at once when the process was finally
/// killed. Measured before and after: with the flush, the first pass's
/// summary lands in a redirected log while the watcher is still idling;
/// without it, nothing does.
///
/// A one-shot compile does not need this, because exiting flushes.
#cfg(any(target="native", target="llvm"))
fn mtsc_flush_stdout() -> Unit {
  let _ = mtsc_native_fflush(0L)
}

///|
/// Nothing to flush: `console.log` is unbuffered, and the wasm targets
/// have no stdio layer of their own.
#cfg(not(any(target="native", target="llvm")))
fn mtsc_flush_stdout() -> Unit {

}

///|
/// The command line, with the first user argument at index 1.
///
/// `@env.args()` does NOT have the same shape on every backend, and
/// `driver.mbt` reads that shape at six places (`argv[1]` as the verb,
/// `mtsc_parse_args(argv, 1)`, `mtsc_run_check(argv, 2)` and its three
/// siblings). Normalizing once here is what keeps those six from each
/// needing to know — the alternative is the one-offset-assumed-in-
/// several-places defect, and it would be found the way this one was:
/// by the CLI type-checking its own 19 MB bundle.
#cfg(not(target="js"))
fn mtsc_argv() -> Array[String] {
  @env.args()
}

///|
/// The same list on the JavaScript backend, where it is shaped
/// differently.
///
/// `moonbitlang/core/env` returns `process.argv` VERBATIM on `js`, and
/// Node puts two entries in front of the user's: the Node executable
/// and the script. The native runtime's argv has one, the program. So
/// dropping the Node executable makes the script the program name and
/// lands the first user argument on index 1 — the shape the rest of the
/// CLI already assumes.
///
/// This was a real bug rather than a hypothetical: without it
/// `mtsc ok.ts --noEmit` under Node read the script path as a second
/// entry and compiled `mtsc.js` — its own bundle — reporting
/// `ParseError("Unexpected token: Gt")` against itself and exiting 1 on
/// a clean file.
///
/// `node -e '…' a b` has no script path, so the drop would eat `a`
/// there. That is stated rather than guarded: a built bundle is never
/// run through `-e`, and under `-e` there is no script for argv[0] to
/// mean anyway.
#cfg(target="js")
fn mtsc_argv() -> Array[String] {
  let argv = @env.args()
  if argv.is_empty() {
    return argv
  }
  let out : Array[String] = []
  for index in 1.. Bool {
  let effective_out = match out_override {
    Some(p) => Some(p)
    None => opts.out
  }
  if opts.mangle && opts.sourcemap {
    println(
      "mtsc: --mangle and --sourcemap can't be combined yet (source positions don't survive the rename pass)",
    )
    return false
  }
  let jsx_cfg = mtsc_build_jsx_config(opts, input_path)
  let output_path = match effective_out {
    Some(p) => p
    None => "output.js"
  }
  if opts.bundle {
    let files : Map[String, String] = Map([])
    let edges : Array[@mtsc_core.MtscModuleEdge] = []
    let aliases : Map[String, String] = Map([])
    let type_props : Map[String, Bool] = Map([])
    let parsed_blocks : Map[String, @ast.TsModuleBlock] = Map([])
    let parse_split : Array[Double] = []
    // `--timing` covers the CLI's own work too. The transform pipeline
    // reports its own phases, but the first measurement of `checker.ts`
    // put 731 ms of 31.6 s inside them — everything that mattered was
    // out here.
    let cli_timer = if opts.timing {
      @transform.PhaseTimer::new()
    } else {
      @transform.PhaseTimer::disabled()
    }
    let load_started = @bench.monotonic_clock_start()
    match
      mtsc_load_bundle_files(
        input_path,
        files,
        aliases,
        opts.externals,
        type_props,
        opts.reserve_typed_props,
        parse_split,
        parsed_blocks,
        edges,
      ) {
      Ok(_) => ()
      Err(e) => {
        println("mtsc: \{e}")
        return false
      }
    }
    // Report the lexer and the recursive descent separately, and keep
    // the remainder (disk reads, path resolution) as its own row.
    let load_total = @bench.monotonic_clock_end(load_started)
    if parse_split.length() == 7 {
      cli_timer.record("cli: tokenize", parse_split[0])
      cli_timer.record("cli: parse (recursive descent)", parse_split[1])
      cli_timer.record("cli: read + decode files", parse_split[2])
      cli_timer.record("cli: resolve relative paths", parse_split[3])
      cli_timer.record("cli: ambient .d.ts scan", parse_split[4])
      cli_timer.record("cli: import edge walk", parse_split[5])
      cli_timer.record("cli: resolve bare specifiers", parse_split[6])
      // Whatever the measured spans do not account for: queue
      // bookkeeping, mostly. The sibling `.d.ts` scan used to be in
      // here and was most of it — on `typescript.js` it read and parsed
      // 98 files and 3.2 MB. A residual is a place to look, not an
      // answer, and this one was labelled after the wrong thing.
      let mut rest = load_total
      for span in parse_split {
        rest = rest - span
      }
      if rest > 0.0 {
        cli_timer.record("cli: load, unattributed", rest)
      }
    } else {
      cli_timer.record("cli: read + parse files", load_total)
    }
    // Type-check the whole graph in one go. Checking each file alone
    // leaves every imported type unresolvable, which both hides real
    // errors and invents fake ones — an identical shape compared against
    // an imported alias reads as a mismatch.
    if !opts.no_check {
      let check_started = @bench.monotonic_clock_start()
      let check_ok = mtsc_typecheck_graph(files, edges)
      cli_timer.record(
        "cli: typecheck",
        @bench.monotonic_clock_end(check_started),
      )
      if !check_ok {
        return false
      }
    }
    // `--listFiles` names the program; `--listFilesOnly` stops there.
    // Both read the loader's `files` map rather than re-walking, and
    // both filter it the way the type-check does: the loader keys a
    // module by BOTH its resolved path and the extension-less form a
    // specifier spelled, so an unfiltered dump names half the files
    // twice.
    if opts.list_files {
      mtsc_list_program_files(files)
    }
    if opts.list_files_only {
      return true
    }
    // `--noEmit` stops after the check. Deliberately here and not
    // before the load: the graph walk is what makes the check a
    // PROGRAM check rather than a per-file one, which is the thing
    // `tsc --noEmit` is asked for in CI.
    if opts.no_emit {
      return true
    }
    // `type_props` collects every property name declared in an
    // interface / type alias / class across the input. We DON'T
    // feed it to the bundler by default: in bundle mode the type
    // contract is erased at runtime (the bundle is self-contained,
    // so no consumer can see the original property names through
    // TypeScript reflection). The harvested list lives behind
    // `--reserve-typed-props` for the library-emission scenario
    // where the bundle's types DO escape and would type-check
    // against a downstream consumer.
    let extra_reserved : Array[String] = []
    if opts.reserve_entry_exports {
      // Re-parse the entry to walk its `export` declarations + the
      // type signatures they reach. This is what the user is
      // actually publishing in the companion `.d.ts`, so everything
      // else (helper interfaces, internal type aliases) is fair
      // game for the property mangler.
      let entry_source = match files.get(input_path) {
        Some(s) => s
        None => ""
      }
      if entry_source != "" {
        let block = (try
          @parser.parse_module_block_from_source(entry_source)
        catch {
          e => Err(e)
        } noraise {
          v => Ok(v)
        }).or({
          block: { stmts: [], stmt_positions: [], },
          imports: [],
          exports: [],
          reexports: [],
          enums: [],
          internal_names: {},
          deprecations: {},
          pure_declarations: {},
          type_aliases: [],
          interfaces: [],
          class_fields: [],
          module_request_order: [],
          type_only_exports: {},
          has_export_equals: false,
        })
        let module_ = (try
          @parser.parse_module_from_source_with_const_values(entry_source, {})
        catch {
          e => Err(e)
        } noraise {
          v => Ok(v)
        }).or(@ast.TsModule::{
          funcs: [],
          interfaces: [],
          type_aliases: [],
          imports: [],
          values: [],
          classes: [],
          local_classes: [],
          namespaces: [],
          enums: [],
          module_augmentations: [],
          strict_property_initialization: true,
          no_implicit_any: true,
          strict_null_checks: true,
          use_define_for_class_fields: false,
          deprecated_compiler_options: [],
          parameter_property_misuses: [],
          strict_mode_misuses: [],
          invalid_decorator_uses: [],
          grammar_misuses: [],
          imported_binding_names: [],
          import_module_specs: [],
          class_base_type_args: [],
          param_optional_initializer_misuses: [],
          interface_member_modifier_misuses: [],
          class_dup_diagnostics: [],
          top_level_stmts: [],
        })
        let local_names : Array[String] = []
        for ex in block.exports {
          local_names.push(ex.local_name)
        }
        for n in @transform.entry_export_property_names(module_, local_names) {
          extra_reserved.push(n)
        }
      }
    } else if opts.reserve_typed_props {
      for n in type_props.keys() {
        extra_reserved.push(n)
      }
    }
    let bundle_opts = @transform.BundleOptions::default()
      .with_mangle(opts.mangle)
      .with_mangle_properties(opts.mangle_properties)
      .with_mangle_properties_shape_color(opts.mangle_properties_shape_color)
      .with_sourcemap(opts.sourcemap)
      .with_treeshake(opts.treeshake)
      .with_minify(opts.minify)
      .with_fold(opts.fold)
      .with_aliases(aliases)
      .with_extra_reserved_props(extra_reserved)
      .with_explain(opts.explain_mangle)
      // The loader above already asked the filesystem about every
      // relative import and printed a line for each one that is not
      // there, so the bundler doesn't need to fail over them a second
      // time — it keeps them verbatim.
      .with_allow_unresolved_imports(true)
      .with_disabled_phases(opts.disabled_phases)
      .with_timing(opts.timing)
      // Hand the loader's parses over rather than have the bundler
      // repeat them.
      .with_parsed_blocks(parsed_blocks)
    let bundle_opts = { ..bundle_opts, jsx: jsx_cfg, }
    let bundle_started = @bench.monotonic_clock_start()
    let result = @transform.bundle_modules(files, input_path, bundle_opts)
    let bundle_micros = @bench.monotonic_clock_end(bundle_started)
    let out = match result {
      Ok(o) => o
      Err(e) => {
        println("mtsc: \{e}")
        return false
      }
    }
    if opts.warn_impure_getters {
      for w in out.warnings {
        println("mtsc: " + w)
      }
    }
    // Reflection that stopped an optimization. Default on, except when
    // the JS is going to stdout and the line would land in the program.
    let want_reflection = match opts.warn_reflection {
      Some(v) => v
      None => effective_out is Some(_)
    }
    if want_reflection && out.reflection_warnings.length() > 0 {
      println(
        "mtsc: warning: reflection keeps every class method. These enumerate NON-enumerable",
      )
      println(
        "mtsc:          properties, which is exactly what a class prototype method is, so no",
      )
      println("mtsc:          analysis can see through them:")
      for r in out.reflection_warnings {
        println("mtsc:   " + r)
      }
    }
    for line in out.explain {
      println(line)
    }
    // Verify the OUTPUT. Deliberately after every pass and from the
    // emitted TEXT: a check that shares the pipeline's in-memory state
    // can share its bugs.
    if opts.verify {
      let violations = @transform.verify_emitted_bundle(out.js)
      for v in violations {
        println("mtsc: verify: \{v.detail}")
      }
      if violations.length() > 0 {
        println("mtsc: verify: \{violations.length()} violation(s)")
      } else {
        println("mtsc: verify: ok")
      }
    }
    if opts.timing {
      // The bundle's own phases, then the difference — what
      // `bundle_modules` spent outside any instrumented pass. Linking,
      // escape analysis and the emit all used to be in here and all
      // now have rows of their own; what is left is the statement
      // concatenation and the per-pass bookkeeping between phases.
      let mut inner = 0.0
      for t in out.timings {
        cli_timer.record(t.0, t.1)
        inner = inner + t.1
      }
      let unattributed = bundle_micros - inner
      if unattributed > 0.0 {
        cli_timer.record("bundle: unattributed", unattributed)
      }
      for line in cli_timer.report() {
        println(line)
      }
    }
    match effective_out {
      Some(path) => {
        let js = match out.sourcemap {
          Some(_) => {
            let base = match path.rev_find("/") {
              Some(i) => path[i + 1:path.length()].to_owned()
              None => path
            }
            out.js + "\n//# sourceMappingURL=" + base + ".map\n"
          }
          None => out.js
        }
        if !write_text(path, js) {
          return false
        }
        match out.sourcemap {
          Some(map_json) => {
            let _ = write_text(path + ".map", map_json)
          }
          None => ()
        }
        if opts.dts {
          // Emit the companion `.d.ts`. Strip a trailing `.js`
          // from the output path (if present) before appending
          // `.d.ts` so `bundle.js` becomes `bundle.d.ts` rather
          // than `bundle.js.d.ts`.
          let dts_path = if path.has_suffix(".js") {
            path[0:path.length() - 3].to_owned() + ".d.ts"
          } else {
            path + ".d.ts"
          }
          let entry_source = match files.get(input_path) {
            Some(s) => s
            None => ""
          }
          let entry_block = (try
            @parser.parse_module_block_from_source(entry_source)
          catch {
            e => Err(e)
          } noraise {
            v => Ok(v)
          }).or({
            block: { stmts: [], stmt_positions: [], },
            imports: [],
            exports: [],
            reexports: [],
            enums: [],
            internal_names: {},
            deprecations: {},
            pure_declarations: {},
            type_aliases: [],
            interfaces: [],
            class_fields: [],
            module_request_order: [],
            type_only_exports: {},
            has_export_equals: false,
          })
          let entry_module = (try
            @parser.parse_module_from_source_with_const_values(entry_source, {})
          catch {
            e => Err(e)
          } noraise {
            v => Ok(v)
          }).or(@ast.TsModule::{
            funcs: [],
            interfaces: [],
            type_aliases: [],
            imports: [],
            values: [],
            classes: [],
            local_classes: [],
            namespaces: [],
            enums: [],
            module_augmentations: [],
            strict_property_initialization: true,
            no_implicit_any: true,
            strict_null_checks: true,
            use_define_for_class_fields: false,
            deprecated_compiler_options: [],
            parameter_property_misuses: [],
            strict_mode_misuses: [],
            invalid_decorator_uses: [],
            grammar_misuses: [],
            imported_binding_names: [],
            import_module_specs: [],
            class_base_type_args: [],
            param_optional_initializer_misuses: [],
            interface_member_modifier_misuses: [],
            class_dup_diagnostics: [],
            top_level_stmts: [],
          })
          // Filter exports through the entry's `@internal` set so
          // anything tagged `/** @internal */` is invisible to
          // downstream consumers' type-checks even when the JS
          // bundle still ships the runtime binding.
          let local_names : Array[String] = []
          for ex in entry_block.exports {
            if !entry_block.internal_names.contains(ex.local_name) {
              local_names.push(ex.local_name)
            }
          }
          let dts_text = @transform.emit_entry_dts(
            entry_module,
            local_names,
            entry_block.deprecations,
          )
          let _ = write_text(dts_path, dts_text)
        }
      }
      None => println(out.js)
    }
    return true
  }
  let source = @fs.read_file(input_path).text() catch {
    e => {
      println("mtsc: read error \{input_path}: \{e}")
      return false
    }
  }
  if !mtsc_typecheck_source(source, input_path) && !opts.no_check {
    return false
  }
  // The single-file path's program is the one file, so the two list
  // options degenerate to printing it.
  if opts.list_files {
    println(input_path)
  }
  if opts.list_files_only {
    return true
  }
  if opts.no_emit {
    return true
  }
  let result = @transform.transform_source_full(
    source,
    jsx_cfg,
    with_sourcemap=opts.sourcemap,
    source_path=input_path,
    output_path~,
    mangle=opts.mangle,
    mangle_properties=opts.mangle_properties,
    minify=opts.minify,
  )
  let out = match result {
    Ok(o) => o
    Err(e) => {
      println("mtsc: \{e}")
      return false
    }
  }
  let js = match (out.sourcemap, effective_out) {
    (Some(_), Some(p)) => {
      let base = match p.rev_find("/") {
        Some(i) => p[i + 1:p.length()].to_owned()
        None => p
      }
      out.js + "\n//# sourceMappingURL=" + base + ".map\n"
    }
    _ => out.js
  }
  match effective_out {
    Some(path) => {
      if !write_text(path, js) {
        return false
      }
      match out.sourcemap {
        Some(map_json) => {
          let _ = write_text(path + ".map", map_json)
        }
        None => ()
      }
    }
    None => println(js)
  }
  true
}