🧊

TypeScriptの`Diagnostics`について

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なものがあることがわかった。(つまり、このスクリプト自体しばらく使われてなさそう・・・)

もうひとつちなむと、コード中にインラインで書かれてるものもある。

https://github.com/search?q=repo%3Amicrosoft%2FTypeScript+%2F%5Cbcode%3A+-%3F%5Cd%2F+path%3A%2F%5Esrc%5C%2F%2F&type=code

コードとしては、-109999がいた。きっとレアケースなんやろう。

コードから判別できる?

もしかして、たとえば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.tscreateSourceFile()とか?
  • そこをルートとして、コードをバンドルする
    • 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()で使われてるのを除外していく作戦もありそうだが、どちらにせよ結局そこで我々が構文エラーと見なすものが処理されてない保証はない。

まとめ

compilerconformanceのテストで参照される.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

🥪…