🧊

続・TypeScriptの`Diagnostics`について

前回はこちら。

TypeScriptのDiagnosticsについて | Memory ice cubes https://leaysgur.github.io/posts/2025/06/13/131109/

前回までのあらすじ

  • TSCのDiagnosticsについて、純粋にコードのパースに際して検証すべきものを知りたかった
  • 単純なJSの構文エラーはもちろん、TS特有の構文エラーを拾いたい
  • しかし、型エラーはいらないし、TSC固有の設定関連もいらない
  • これをどう効率よく仕分けられるかを探してた

TSCのスナップショットテストである./tests/cases/compiler|conformanceから参照される、./tests/baselines/reference配下で実際に記録されてるエラーコードが調査すべき母集団。

🍀 Found 700 error diagnostics are used for compiler tests.
🍀 Found 695 error diagnostics are used for conformance tests.

🍀 Found 950 error diagnostics are used for compiler|conformance tests.

この950を手作業で仕分けたくないので、もっと手堅く減らすことはできないか?という主旨。

で、まずは、エラーコードだけを見て、1000番台なら構文エラー・・・というようには分けられるかを検討したが、どうやらエラーコードの区別は曖昧で、この作戦はダメだった。

次に、./src/compiler配下のコードに対して、

  • sg -p 'Diagnostics.$KEY'を実行して
  • $KEYを元に、実際のDiagnosticsを定義してるファイルからexportされたオブジェクトを引き
  • エラーカテゴリのコードだけを抽出

というアプローチを検討した。 参考までに抜粋しておくと、以下のような数字が取れてた。

🍀 Found 67 error diagnostics are used in "src/compiler/scanner.ts".
🍀 Found 78 error diagnostics are used in "src/compiler/parser.ts".
🍀 Found 29 error diagnostics are used in "src/compiler/binder.ts".
🍀 Found 905 error diagnostics are used in "src/compiler/checker.ts".

ただこのアプローチには問題があり。

  • Diagnostics.$KEYというASTすべてが対象になってしまって、コンテキストを汲んでない
    • パースに関係ない処理が定義されていたら拾ってしまう
  • importしてる先のコードを拾えない
    • TSCは各コンポーネントが相互に密接に関係してて、_namespaces/ts.tsという巨大barrel exportに依存してる

これ解決できないか?というのが今回の記事の話。

バンドルしてしまう

たとえばparser.tsなら、createSourceFile()がエントリーポイントになっていて、そこからパース処理がはじまる。

なのでそこから辿って、rollupみたいなバンドラーでバンドルすれば、本当に必要なDiagnosticsだけが取得できるのでは?Treeshakeも効くならば、懸念点は解決できるのでは?と閃いた。

それをやってみたのがこちら。

import { rollup } from "rollup";
import virtual from "@rollup/plugin-virtual";
import esbuild from "rollup-plugin-esbuild";
import { parse, Lang } from "@ast-grep/napi";

const parserCode = await bundleWithTreeshake(`
import { createSourceFile } from "./src/compiler/parser.ts";
const ast = createSourceFile("dummy.ts", code, 99);
`);

// NOTE: Parser uses Scanner internally
const parserUsedDiagnostics = extractErrorDiagnostics(parserCode);
console.log(
  "🍀",
  parserUsedDiagnostics.size,
  "diagnostics used in the scanner+parser",
);

// ---

// Bundle the code:
// - to resolve `import` inside the each component
// - to drop unused code
async function bundleWithTreeshake(entry: string) {
  const bundle = await rollup({
    input: "entry",
    output: { format: "esm" },
    treeshake: {
      moduleSideEffects: false,
      preset: "smallest",
      propertyReadSideEffects: false,
    },
    plugins: [virtual({ entry }), esbuild({})],
    onLog: (level, log, handler) => {
      // This often happens with TSC...
      if (log.code !== "CIRCULAR_DEPENDENCY") handler(level, log);
    },
  });
  const {
    output: [{ code }],
  } = await bundle.generate({});
  bundle.close();

  return code;
}

function extractErrorDiagnostics(code: string) {
  const sgRoot = parse(Lang.TypeScript, code).root();
  const diagDecl = sgRoot.find("const Diagnostics = $$$")!;
  const diagCalls = diagDecl.findAll(
    "diag($CODE, DiagnosticCategory.Error, $_, $MESSAGE)",
  );
  const diagCallsWithOpts = diagDecl.findAll(
    "diag($CODE, DiagnosticCategory.Error, $_, $MESSAGE, $$$)",
  );

  const errorDiagnostics = new Map<number, string>();
  for (const diagCall of [...diagCalls, ...diagCallsWithOpts]) {
    const code = Number(diagCall.getMatch("CODE")!.text());
    const message = diagCall.getMatch("MESSAGE")!.text();
    errorDiagnostics.set(code, message);
  }

  return errorDiagnostics;
}

virtualエントリー初めて使ったけど、これはプラグインの対象にならないことを知った)

バンドルするエントリーポイントの定義は以下のとおり。

  • parser: createSourceFile()
  • binder: bindSouceFile()
  • checker: createTypeChecker()

すると結果はこうなった。

🍀 151 diagnostics used in the scanner+parser
🍀 28 diagnostics used in the binder
🍀 904 diagnostics used in the checker
🍀 1083 diagnostics used in the scanner+parser, binder and checker

どうやらbinderはparserに依存していて、checkerもbinderとparserに依存してるようだったので、この数字はそれらを差し引いてある。

考察

雑にファイルごとにsgして得られた数字と、大きくは変わってないのは良かった。

  • sgよりも数字が増えてるものは、import先にやっぱりユースケースがあったものであろう
  • sgよりも数字が減ってるものは、パースに直接関係ない処理があったのであろう

ただ総数が950より増えて1083になってるのは、想定外だった・・・。

これはつまり、compiler|conformanceのテスト中には現れてない、テストされてないものがコード中にあるかもしれないってこと。

compiler|conformanceとは別の、project|transpile系のテストで使われてるものかもしれないし、単にテストされてないだけかもしれない。

こうなってくると、これらの数字がどれくらいテストケースでカバーされてるのか気になってくる。

テストで使用されてた950種のコードを用意して、各ファイルで使用されてるコードが、実際にテストされているかを調べる。

const parserUsedButNotTested = Array.from(parserUsedDiagnostics).filter(
  ([code]) => !allTestedErrorDiagnostics.has(code),
);
console.warn(
  "👀",
  parserUsedButNotTested.length,
  "diagnostics are used in the scanner+parser, but not tested in the compiler|conformance tests.",
);

するとこうなった。

  • parser: 36/151
  • binder: 2/28
  • checker: 178/904

これらが、“compiler|conformanceのテストに出てこない/けどコードで使用されてる”コードの数。

これをパースに関係ないとみなすか、単にテストがないだけとみなすか。まあでもテストされてないなら検証しようがないし、無視していいのか?

最終的に

  • 950: compiler|conformanceテストに登場するエラーコード

これを精査すべき必要があったけど、

  • 115(=151-36): parser#createSourceFile()で使用されていて、テストに登場するエラーコード
  • 26(=28-2): binder#bindSourceFile()で使用されていて、テストに登場するエラーコード

この2つは確実にパース関連なので安全に除外できるとして、残ったのは、

  • 809(=950-(115+26)): テストに登場するコードのうち、テストされているparser+binder関連のコードを差し引いたもの

これを精査すればいいってことになる・・・が、ぜんぜん減ってない! (ならもう950を精査したらよくない?堅牢になったとはいえ、完璧である保証はないし・・・)