OXCでTypeScriptをパースするとき、どこまでを構文エラーとして報告するかを検討しており。
TSCのtsProgram.getSyntacticDiagnostics()
で取れるものは、もちろん構文エラーでいい。
ただ、tsProgram.getSemanticDiagnostics()
で取れるものにも、これは構文エラーでは?ってのは多々ある。たとえばconst a;
とか、class X { private private x = 1 }
とか。
むしろこれをsemanticに分類するTSの気持ちが知りたい。
いやまあ、TSCはrecoverableなパーサーなので、致命的なものだけがsyntacticで、それ以外はsemanticって感じなんやろうけど。あとはこの境界はおそらく曖昧なものであって、実際には厳密に定義できないとかかもしれない。
ともあれ、型エラーみたいなものだけを除外する手立ては何もないのか?というのをざっくり調べた結果のメモ。
まず仕組み
TSのリポジトリでは、足回りにHerebyを使ってる。そしてDiagnostics
関連だと、以下のコマンドを実行することになってた。
node ./scripts/processDiagnosticMessages.mjs ./src/compiler/diagnosticMessages.json
diagnosticMessages.json
が元ネタで、これを実行すると、src/compiler/diagnosticInformationMap.generated.ts
が生成される。
https://github.com/microsoft/TypeScript/blob/main/src/compiler/diagnosticMessages.json
そうすると、exportされているDiagnostics
オブジェクトを、各コードがimportして使えるようになるという仕組み。
export const Diagnostics = {
Unterminated_string_literal: diag(1002, DiagnosticCategory.Error, "Unterminated_string_literal_1002", "Unterminated string literal.") as DiagnosticMessage,
Identifier_expected: diag(1003, DiagnosticCategory.Error, "Identifier_expected_1003", "Identifier expected.") as DiagnosticMessage,
_0_expected: diag(1005, DiagnosticCategory.Error, "_0_expected_1005", "'{0}' expected.") as DiagnosticMessage,
// ...
DiagnosticMessage
はそれぞれcode
が割り振られてて、TS1002
みたいなやつがそれ。このエラーコードは重複することはないようで、元ネタには全部で2119件が定義されてた。多い!
カテゴリもいくつかあって、そのうち”Error”カテゴリに属するものは、1343件だった。
余談
ちなみに、./scripts/find-unused-diagnostic-messages.mjs
ってスクリプトも用意されてて、使われてないものをチェックできる感じだった。
ただ・・・、中身は愚直に全キーを直列のgrep
で./src
配下を探し歩くスクリプトであり、実行に7分もかかったのに肝心のメッセージが見切れててすごく残念だった。
その上、今時点でも130件くらいはunusedなものがあることがわかった。(つまり、このスクリプト自体しばらく使われてなさそう・・・)
もうひとつちなむと、コード中にインラインで書かれてるものもある。
コードとしては、-1
と0
と9999
がいた。きっとレアケースなんやろう。
コードから判別できる?
もしかして、たとえば1xxxシリーズが構文エラーってことになってないか?と思って調べた。
まずはレンジに分けてみる。
jq -r 'to_entries | map(.value.code) | group_by(. / 1000 | floor) | map("\(.[0] / 1000 | floor)xxx: \(length)")[]' ./src/compiler/diagnosticMessages.json
すると、こういう分布になった。 ざっくり眺めてラベルもつけてみたけど、まあ仮説は早くも崩れ去った。
- 1xxx(461): JSとしての文法エラー関連
- 2xxx(536): 型エラー、TSとしての文法エラー関連
- 4xxx(111): import/export関連
- 5xxx(66): fs, tsconfig関連
- 6xxx(489):
compilerOptions
関連 - 69xxx(1): (
69010
だけ、6910
のtypoっぽい) - 7xxx(55): 型推論関連
- 8xxx(36): エディタ、JSDoc関連
- 9xxx(34):
--isolatedDeclarations
関連 - 17xxx(21): JSX文法エラー関連
- 18xxx(52): ES2015+関連
- 80xxx(10): “Suggestion”カテゴリ関連
- 90xxx(55): “Message”カテゴリ関連
- 95xxx(192): “Message”カテゴリ関連
パーサーとしては不要なレンジと断定できそうなものもあるけど、やっぱりコードだけでは心許ない。
母数を減らしたい
TypeScriptのテストケースに、./tests/cases/compiler
とか、./tests/cases/conformance
というディレクトリがある。
いわゆるパーサー関連でよく参照されるケースたちで、その中には、TSCが明示的にエラーを出力するべき!というテストケースもある。
その証左となるファイルたちは./tests/baselines/reference
にあり、テストケース名.errors.txt
というファイルがあるなら、そのケースではエラーを出力することが期待されるという感じ。
なのでつまり、これらのテストケースの中から.errors.txt
が存在するケースだけを割り出し、そこにある全コードを得ることで、パーサーが出力すべき母数にできるはず。
純粋な型エラーなんかを除外する必要はまだあるけど、精査すべき母数は確実に減らすことはできるはず!
コードはこちら。
/// <reference lib="esnext" />
import { readFile } from "node:fs/promises";
import { glob } from "glob";
const TARGET_TESTS = ["compiler", "conformance"];
const allUsedErrorDiagnostics: Map<number, string> = new Map();
for (const t of TARGET_TESTS) {
const testFilePaths = await glob(`${t}/**/*.ts`, { cwd: "./tests/cases" });
const usedErrorDiagnostics: Map<number, string> = new Map();
for (const testFilePath of testFilePaths) {
const testFileName = testFilePath.split("/").pop()!.replace(".ts", "");
try {
const errorsText = await readFile(
`./tests/baselines/reference/${testFileName}.errors.txt`,
"utf8",
);
const diagnostics = extractCodeAndMessages(errorsText);
for (const [code, message] of diagnostics) {
usedErrorDiagnostics.set(code, message);
allUsedErrorDiagnostics.set(code, message);
}
} catch {
// This test case does not expect any error diagnostics, so skip it
continue;
}
}
console.log(
"🍀",
`Found ${usedErrorDiagnostics.size} error diagnostics are used for ${t} tests.`,
);
showRangeStats(usedErrorDiagnostics);
console.log();
}
console.log(
"🍀",
`Found ${allUsedErrorDiagnostics.size} error diagnostics are used for ${TARGET_TESTS.join("|")} tests.`,
);
// ---
function extractCodeAndMessages(errorsText: string) {
const lines = errorsText.split("\n");
const diagnostics = new Map();
for (const line of lines) {
if (!line.startsWith("!!! error TS")) continue;
const [codePart, messagePart] = line.split(": ");
const [, codeString] = codePart.split("error TS");
const code = Number(codeString);
const message = messagePart.trim();
// -1, 0, 9999 is inlined in the source code, we don't need them
if (code <= 0 || code === 9999) continue;
diagnostics.set(code, message);
}
return diagnostics;
}
function showRangeStats(diagnostics: Map<number, string>) {
const sortedDiagnostics = [...diagnostics.entries()].sort(
(a, b) => a[0] - b[0],
);
const diagnosticsByCodeRange: Record<string, number[]> = {};
for (const [code] of sortedDiagnostics) {
// 1000 => 1xxx, 1001 => 1xxx, 1999 => 1xxx, etc
const range = String(Math.floor(code / 1000) * 1000).replaceAll("0", "x");
diagnosticsByCodeRange[range] ??= [];
diagnosticsByCodeRange[range].push(code);
}
for (const [range, codes] of Object.entries(diagnosticsByCodeRange))
console.log(`- ${range}: ${codes.length}`);
}
実行するとこうなった。
🍀 Found 688 error diagnostics are used for compiler tests.
- 1xxx: 213
- 2xxx: 326
- 4xxx: 10
- 5xxx: 25
- 6xxx: 21
- 7xxx: 33
- 8xxx: 18
- 9xxx: 19
- 17xxx: 10
- 18xxx: 13
🍀 Found 672 error diagnostics are used for conformance tests.
- 1xxx: 244
- 2xxx: 314
- 4xxx: 12
- 5xxx: 12
- 6xxx: 8
- 7xxx: 21
- 8xxx: 20
- 9xxx: 2
- 17xxx: 10
- 18xxx: 29
🍀 Found 927 error diagnostics are used for compiler|conformance tests.
やはりコードから機械的に割り出すことはできなそう。
母数が2000超えから一気に半減したのはうれしいが、消えるかと思われた8xxxや9xxxすら残ってる。
まぁ最悪、927件のエラーメッセージを手動で仕分ければいいことはわかったけど、それは最終手段としたい。
compiler配下のコードで使用されてるもの
TSCを構成するコードのそれぞれで、どのDiagnostics
を参照してるかを調べる。
ast-grep
を使ってDiagnostics.xxx
というアクセスの仕方をしてる部分を抽出する作戦。
/// <reference lib="esnext" />
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Diagnostics } from "./src/compiler/diagnosticInformationMap.generated";
import { DiagnosticCategory } from "./src/compiler/types";
const targetComponents = await execCmd(
"sg -p 'Diagnostics.$$$' ./src/compiler --json | jq '.[].file' | sort | uniq",
).then((r) => r.split("\n"));
for (const c of targetComponents) {
const refs = await execCmd(
`sg -p 'Diagnostics.$$$' ./${c} --json | jq '.[].text' | sort | uniq`,
);
const usedErrorDiagnostics: Map<number, string> = new Map();
for (const line of refs.split("\n")) {
const ref = line.slice(1, -1); // strip "
const [, id] = ref.split("."); // Diagnostics.xxx
const digagnostic = Diagnostics[id];
// NOTE: Only error diagnostics are needed
if (digagnostic.category !== DiagnosticCategory.Error) continue;
usedErrorDiagnostics.set(digagnostic.code, digagnostic.message);
}
// Skip if no error diagnostics are used
if (usedErrorDiagnostics.size === 0) continue;
console.log(
"🍀",
`Found ${usedErrorDiagnostics.size} error diagnostics are used in ${c}.`,
);
showRangeStats(usedErrorDiagnostics);
console.log();
}
// ---
async function execCmd(cmd: string) {
const execAsync = promisify(exec);
try {
const { stdout } = await execAsync(cmd);
return stdout.trim();
} catch (error) {
console.error("Failed to execute command:", error);
process.exit(1);
}
}
function showRangeStats(diagnostics: Map<number, string>) {
const sortedDiagnostics = [...diagnostics.entries()].sort(
(a, b) => a[0] - b[0],
);
const diagnosticsByCodeRange: Record<string, number[]> = {};
for (const [code] of sortedDiagnostics) {
// 1000 => 1xxx, 1001 => 1xxx, 1999 => 1xxx, etc
const range = String(Math.floor(code / 1000) * 1000).replaceAll("0", "x");
diagnosticsByCodeRange[range] ??= [];
diagnosticsByCodeRange[range].push(code);
}
for (const [range, codes] of Object.entries(diagnosticsByCodeRange))
console.log(`- ${range}: ${codes.length}`);
}
これを実行すると・・・、
🍀 Found 29 error diagnostics are used in "src/compiler/binder.ts".
- 1xxx: 18
- 2xxx: 7
- 5xxx: 1
- 7xxx: 2
- 18xxx: 1
🍀 Found 905 error diagnostics are used in "src/compiler/checker.ts".
- 1xxx: 258
- 2xxx: 502
- 4xxx: 20
- 5xxx: 7
- 6xxx: 15
- 7xxx: 39
- 8xxx: 13
- 17xxx: 12
- 18xxx: 39
🍀 Found 38 error diagnostics are used in "src/compiler/commandLineParser.ts".
- 1xxx: 3
- 5xxx: 16
- 6xxx: 12
- 8xxx: 1
- 17xxx: 2
- 18xxx: 4
🍀 Found 7 error diagnostics are used in "src/compiler/executeCommandLine.ts".
- 5xxx: 6
- 6xxx: 1
🍀 Found 3 error diagnostics are used in "src/compiler/moduleNameResolver.ts".
- 2xxx: 2
- 6xxx: 1
🍀 Found 78 error diagnostics are used in "src/compiler/parser.ts".
- 1xxx: 57
- 2xxx: 7
- 8xxx: 3
- 17xxx: 7
- 18xxx: 4
🍀 Found 172 error diagnostics are used in "src/compiler/program.ts".
- 1xxx: 77
- 2xxx: 15
- 5xxx: 35
- 6xxx: 16
- 7xxx: 2
- 8xxx: 15
- 17xxx: 3
- 18xxx: 9
🍀 Found 2 error diagnostics are used in "src/compiler/programDiagnostics.ts".
- 2xxx: 2
🍀 Found 67 error diagnostics are used in "src/compiler/scanner.ts".
- 1xxx: 64
- 6xxx: 2
- 18xxx: 1
🍀 Found 18 error diagnostics are used in "src/compiler/transformers/declarations.ts".
- 2xxx: 2
- 4xxx: 4
- 5xxx: 1
- 6xxx: 2
- 7xxx: 1
- 9xxx: 8
🍀 Found 115 error diagnostics are used in "src/compiler/transformers/declarations/diagnostics.ts".
- 4xxx: 87
- 9xxx: 28
🍀 Found 2 error diagnostics are used in "src/compiler/tsbuildPublic.ts".
- 6xxx: 2
🍀 Found 8 error diagnostics are used in "src/compiler/utilities.ts".
- 1xxx: 1
- 2xxx: 3
- 5xxx: 1
- 7xxx: 3
🍀 Found 3 error diagnostics are used in "src/compiler/utilitiesPublic.ts".
- 6xxx: 3
ある程度の傾向はわかるけど、決め手に欠ける・・・。もちろん変数名が違ったりすると引っかからないので、完璧でもない。
TSCのコードベースでは、_namespaces/ts.ts
というやつが長大なBarrel exportをしてて、それを各parser.ts
やらが利用する形になってるので、依存グラフまで追わないと相互関係がわからない。
結局これを突きつめるとするなら、
- エントリーポイントにあたりをつける
parser.ts
のcreateSourceFile()
とか?
- そこをルートとして、コードをバンドルする
- Miifyはせず、Tree shakingに期待する
- バンドルされた結果に対して、同様に
Diagnostics.xxx
の参照を見つける
くらいしないとダメそう。ただそれでも、checker.ts
が参照してる2xxxシリーズのうち、どれが不要かは判断できない。
parseErrorとgrammarError
TSCのコードを眺めてると、Diagnostics.xxx
を使ってるコードの傾向がなんとなく見えてきて、
parseErrorAt()
parseErrorAtCurrentToken()
grammarErrorOnNode()
grammarErrorOnFirstToken()
みたいな使われ方をしてるのがわかる。
なので、これらの呼び出しだけに絞ってみれば?という作戦。
/// <reference lib="esnext" />
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Diagnostics } from "./src/compiler/diagnosticInformationMap.generated";
import { DiagnosticCategory } from "./src/compiler/types";
const SG_YML = `
id: parse-or-grammar-error
language: TypeScript
rule:
pattern: $FN($$$)
constraints:
FN: { regex: "^(parse|grammar)Error" }
`;
const targetCalls = await execCmd(
`sg scan --inline-rules '${SG_YML}' --json | jq '.[].text' | sort | uniq`,
);
let noDirectCallCount = 0;
const usedErrorDiagnostics: Map<number, string> = new Map();
for (const line of targetCalls.split("\n")) {
const [, argsPartAndCloseParen] = line.split("(");
const [argsPart] = argsPartAndCloseParen.split(")");
const args = argsPart.split(", ");
const diagnosticArg = args.find((arg) => arg.startsWith("Diagnostics."));
if (!diagnosticArg) {
// console.warn("No direct `Diagnostics.` call found in:", line);
noDirectCallCount++;
continue;
}
const [, id] = diagnosticArg.split("."); // Diagnostics.xxx
const digagnostic = Diagnostics[id];
// NOTE: Only error diagnostics are needed
if (digagnostic.category !== DiagnosticCategory.Error) continue;
usedErrorDiagnostics.set(digagnostic.code, digagnostic.message);
}
console.log(
"🍀",
`Found ${usedErrorDiagnostics.size} error diagnostics are used in (parse|grammar)Error calls.`,
);
showRangeStats(usedErrorDiagnostics);
console.warn(
"⚠️",
`Found ${noDirectCallCount} calls to (parse|grammar)Error without direct Diagnostics reference.`,
);
// ---
async function execCmd(cmd: string) {
const execAsync = promisify(exec);
try {
const { stdout } = await execAsync(cmd);
return stdout.trim();
} catch (error) {
console.error("Failed to execute command:", error);
process.exit(1);
}
}
function showRangeStats(diagnostics: Map<number, string>) {
const sortedDiagnostics = [...diagnostics.entries()].sort(
(a, b) => a[0] - b[0],
);
const diagnosticsByCodeRange: Record<string, number[]> = {};
for (const [code] of sortedDiagnostics) {
// 1000 => 1xxx, 1001 => 1xxx, 1999 => 1xxx, etc
const range = String(Math.floor(code / 1000) * 1000).replaceAll("0", "x");
diagnosticsByCodeRange[range] ??= [];
diagnosticsByCodeRange[range].push(code);
}
for (const [range, codes] of Object.entries(diagnosticsByCodeRange))
console.log(`- ${range}: ${codes.length}`);
}
結果はこう。
🍀 Found 210 error diagnostics are used in (parse|grammar)Error calls.
- 1xxx: 155
- 2xxx: 20
- 5xxx: 4
- 7xxx: 3
- 8xxx: 3
- 17xxx: 9
- 18xxx: 16
⚠️ Found 42 calls to (parse|grammar)Error without direct Diagnostics reference.
210件は、思ってたよりちょっと少ない・・・。 42件の呼び出し元までちゃんと解決してやらんとダメそうか。
反対に、checker.ts
の中のcheckXxx()
で使われてるのを除外していく作戦もありそうだが、どちらにせよ結局そこで我々が構文エラーと見なすものが処理されてない保証はない。
まとめ
compiler
とconformance
のテストで参照される.errors.txt
に登場する927件のコードがmaxの母数になるのは確定でよいはず。
しかし、そこから純粋な型エラー類を除外する方法は、まだ確立できていない。
全件を精査するしかないとして、これらのテストケースに対してgetSyntacticDiagnostics()
を実行して、エラーになった分は確実に構文エラーとして間引くことはできそうだが・・・。(おそらくscanner.ts
+parser.ts
で見つかった140件くらいをユニークにした数)
なんかもっといいやり方はないだろうか〜。
Some syntactic diagnostic errors erroneously reported as semantic · Issue #52011 · microsoft/TypeScript https://github.com/microsoft/TypeScript/issues/52011
🥪…