前回はこちら。
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に依存してる
- TSCは各コンポーネントが相互に密接に関係してて、
これ解決できないか?というのが今回の記事の話。
バンドルしてしまう
たとえば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を精査したらよくない?堅牢になったとはいえ、完璧である保証はないし・・・)