improve(check): skip typecheck lanes for comment-only TypeScript edits (#158796)

* perf(check): skip typecheck lanes for comment-only TypeScript changes

Compare regular TypeScript sources against the merge base with the native parser and shared AST-aware token walker. Preserve ASI boundaries, directives, path lifecycle safety, and all non-typecheck gates; keep unguarded release metadata comparisons conservative.

* fix(check): reject lossy UTF-8 in comment-only typecheck classification

* fix(check): treat TypeScript pragmas case-insensitively in comment-only classification

* fix(check): keep token adjacency in comment-only typecheck classification

* test(check): satisfy array destructuring lint in classifier cases

* fix(check): keep tagged JSDoc pinned in comment-only typecheck classification

* fix(check): pin trivia around tagged JSDoc in comment-only classification

* fix(test): keep the comment-only classifier test on Node
This commit is contained in:
Peter Steinberger 2026-09-26 10:49:27 -07:00 • committed by GitHub
parent 1c415c5582
commit 5a27fda950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 461 additions and 45 deletions

View file

@ -80,6 +80,7 @@ Native dependency policy:
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
- `pnpm test:changed` expands changed git paths into cheap scoped lanes by default: direct test edits, sibling `*.test.ts` files, explicit source mappings, and local import-graph dependents. Config/setup/package edits do not broad-run tests unless you explicitly use `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed`.
- `pnpm check:changed` is the normal smart local check gate for narrow work. It classifies the diff into core, core tests, extensions, extension tests, apps, docs, release metadata, live Docker tooling, and tooling, then runs the matching typecheck, lint, and guard commands. Selected paths also schedule targeted Vitest owner tests via `pnpm test:serial`; use `pnpm test:changed` or explicit `pnpm test <target>` for additional test proof matching the touched contract. Release metadata-only version bumps run targeted version/config/root-dependency checks, with a guard that rejects package changes outside the top-level version field.
- In the default Git diff mode against `HEAD`, comment/whitespace-only TypeScript edits skip their typecheck lanes and core graph boundary check when tokens and line-break boundaries match the merge base. Files with TypeScript/JSX directives, triple-slash comments, parse errors, or path lifecycle changes retain typechecking, as do JavaScript files. Lint, formatting, ratchets, guards, and tests keep the full changed-path scope. Staged, explicit-path, and non-`HEAD` comparisons retain normal typecheck selection; `--dry-run` reports any skipped paths.
- Live Docker ACP harness edits run focused checks: shell syntax for the live Docker auth scripts and a live Docker scheduler dry-run. `package.json` changes are included only when the diff is limited to `scripts["test:docker:live-*"]`; dependency, export, version, and other package-surface edits still use the broader guards.
- Import-light unit tests from agents, commands, plugins, auto-reply helpers, `plugin-sdk`, and similar pure utility areas route through the `unit-fast` lane, which skips `test/setup-openclaw-runtime.ts`; stateful/runtime-heavy files stay on the existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map changed-mode runs to explicit sibling tests in those light lanes, so helper edits avoid rerunning the full heavy suite for that directory.

View file

@ -60,6 +60,7 @@ type CiLintSelection = {
};
type ChangedCheckPlanOptions = {
typecheckResult?: ChangedLaneResult;
lintOnly?: boolean;
lintSelection?: CiLintSelection;
lintThreads?: 1 | 8;
@ -830,6 +831,15 @@ export function createChangedCheckPlan(
addTestTempCreationReport();
const lanes = result.lanes;
const typecheckResult = options.typecheckResult ?? result;
// Release metadata is exempt only with its validating guard, selected by the
// full diff. Filtering trivia must not grant that exemption to package changes.
const typeLanes =
typecheckResult.lanes.releaseMetadata &&
!lanes.releaseMetadata &&
!typecheckResult.paths.every((file) => getChangedPathFacts(file).surface === "docs")
? lanes
: typecheckResult.lanes;
const runAll =
lanes.all ||
(options.lintOnly &&
@ -842,8 +852,20 @@ export function createChangedCheckPlan(
// Typechecking alone accepts extension imports; the graph guard also covers
// shared test/tooling dependencies that core tests can pull into their graph.
const narrowCoreTests = getChangedCoreTestPaths(result) !== undefined;
if (runAll || lanes.core || lanes.coreTests || lanes.ui || lanes.tooling) {
const narrowCoreTests = getChangedCoreTestPaths(typecheckResult) !== undefined;
const liveDockerTypes =
typeLanes.liveDockerTooling &&
typecheckResult.paths.some(
(changedPath) => getChangedPathFacts(changedPath).surface === "source",
);
if (
typeLanes.all ||
typeLanes.core ||
typeLanes.coreTests ||
typeLanes.ui ||
typeLanes.tooling ||
liveDockerTypes
) {
add("core tsgo graph boundary", ["lint:tmp:tsgo-core-boundary"]);
if (narrowCoreTests) {
commands.at(-1)!.coreTestCheck = "checkBoundary";
@ -877,40 +899,45 @@ export function createChangedCheckPlan(
add("database-first legacy-store guard", ["check:database-first-legacy-stores"]);
add("media download helper guard", ["check:media-download-helpers"]);
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
} else if (shouldRunControlUiI18nVerify(result.paths)) {
addLint("Control UI i18n catalog", ["lint:ui:i18n"]);
}
if (typeLanes.all) {
addTypecheck("typecheck all", ["tsgo:all"]);
} else {
if (typeLanes.core) {
addTypecheck("typecheck core", ["tsgo:core"]);
}
if (typeLanes.coreTests || liveDockerTypes) {
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
if (narrowCoreTests) {
commands.at(-1)!.coreTestCheck = "checkTypes";
}
}
if (typeLanes.ui) {
addTypecheck("typecheck UI", ["tsgo:ui"]);
}
if (typeLanes.extensions) {
addTypecheck("typecheck extensions", ["tsgo:extensions"]);
}
if (typeLanes.extensionTests) {
addTypecheck("typecheck extension tests", ["tsgo:extensions:test"]);
}
if (typeLanes.scripts) {
addTypecheck("typecheck scripts", ["tsgo:scripts"]);
}
if (typeLanes.testRoot) {
addTypecheck("typecheck test root", ["tsgo:test:root"]);
}
}
if (runAll) {
addLint("lint", ["lint"]);
add("runtime import cycles", ["check:import-cycles"]);
return finishPlan("all");
}
if (shouldRunControlUiI18nVerify(result.paths)) {
addLint("Control UI i18n catalog", ["lint:ui:i18n"]);
}
if (lanes.core) {
addTypecheck("typecheck core", ["tsgo:core"]);
}
if (lanes.coreTests) {
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
if (narrowCoreTests) {
commands.at(-1)!.coreTestCheck = "checkTypes";
}
}
if (lanes.ui) {
addTypecheck("typecheck UI", ["tsgo:ui"]);
}
if (lanes.extensions) {
addTypecheck("typecheck extensions", ["tsgo:extensions"]);
}
if (lanes.extensionTests) {
addTypecheck("typecheck extension tests", ["tsgo:extensions:test"]);
}
if (lanes.scripts) {
addTypecheck("typecheck scripts", ["tsgo:scripts"]);
}
if (lanes.testRoot) {
addTypecheck("typecheck test root", ["tsgo:test:root"]);
}
if (lanes.core || lanes.coreTests || lanes.ui) {
// CSS is covered by targeted Stylelint below. Other non-Oxlint core/UI
// inputs keep the full lane so changed checks do not silently drop lint.
@ -953,8 +980,6 @@ export function createChangedCheckPlan(
lanes.liveDockerTooling &&
result.paths.some((changedPath) => getChangedPathFacts(changedPath).surface === "source")
) {
add("core tsgo graph boundary", ["lint:tmp:tsgo-core-boundary"]);
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
addLint("lint core", ["lint:core"]);
}
if (lanes.extensions || lanes.extensionTests) {
@ -1396,7 +1421,7 @@ export async function runChangedCheck(
const coreTestCheck = plan.commands.some((command) => command.coreTestCheck)
? (await import("./run-tsgo-core-test-shards.mts")).createChangedCoreTestCheck(
getChangedCoreTestPaths(result)!,
getChangedCoreTestPaths(options.typecheckResult ?? result)!,
createSparseTsgoSkipEnv(childEnv),
)
: undefined;
@ -1424,7 +1449,16 @@ function printPlan(
) {
const prefix = options.dryRun ? "[check:changed:dry-run]" : "[check:changed]";
console.error(`${prefix} lanes=${plan.summary || "none"}`);
if (result.extensionImpactFromCore) {
if (options.typecheckResult) {
const retained = new Set(options.typecheckResult.paths);
const inert = result.paths.filter((file) => !retained.has(file));
if (inert.length) {
console.error(
`${prefix} comment/whitespace-only TypeScript changes; typecheck lanes skip: ${inert.slice(0, 8).join(", ")}${inert.length > 8 ? ` (+${inert.length - 8} more)` : ""}`,
);
}
}
if ((options.typecheckResult ?? result).extensionImpactFromCore) {
console.error(`${prefix} extension-impacting surface; extension typecheck included`);
}
for (const reason of result.reasons) {
@ -1675,6 +1709,24 @@ async function main() {
head: args.head,
staged: args.staged,
});
let typecheckResult: ChangedLaneResult | undefined;
if (
!args.staged &&
!args.noChanges &&
args.paths.length === 0 &&
!argv.includes("--") &&
args.head === "HEAD"
) {
const { findTypecheckInertPaths } = await import("./lib/typecheck-inert.mts");
const inert = new Set(findTypecheckInertPaths({ paths, base: args.base ?? "origin/main" }));
if (inert.size) {
typecheckResult = detectChangedLanesForPaths({
paths: paths.filter((file) => !inert.has(file)),
base: args.base ?? "origin/main",
head: args.head,
});
}
}
if (
shouldDelegateChangedCheckToCrabbox(argv, process.env, {
result,
@ -1698,12 +1750,14 @@ async function main() {
process.exitCode = delegated.backendUnavailable
? await runChangedCheck(result, {
...args,
typecheckResult,
explicitPaths: args.paths.length > 0,
})
: delegated.exitCode;
} else {
process.exitCode = await runChangedCheck(result, {
...args,
typecheckResult,
explicitPaths: args.paths.length > 0,
});
}

View file

@ -66,6 +66,7 @@ const nativeCompilerTestFiles = [
"test/scripts/native-typescript.test.ts",
"test/scripts/nodes-cli-import-closure.test.ts",
"test/scripts/ts-topology.test.ts",
"test/scripts/typecheck-inert.test.ts",
"test/test-helper-extension-import-boundary.test.ts",
];
// Bun fork 3ff0efc82217775e04094a1d4402d7c6932ecb24 failed or added skips in these files.

View file

@ -310,11 +310,13 @@ export function unwrapExpression(expression: ts.Expression) {
}
}
export function collectTypeScriptCommentRanges(
/** Walk literal spans and scanned gaps without interpreting literal text as trivia. */
export function walkTypeScriptTokens(
sourceFile: ts.SourceFile,
): Iterable<ts.CommentRange> {
onToken: (kind: ts.SyntaxKind, pos: number, end: number) => void,
): void {
const source = sourceFile.getFullText();
const literals: Array<{ pos: number; end: number }> = [];
const literals: Array<{ kind: ts.SyntaxKind; pos: number; end: number }> = [];
const visit = (node: ts.Node): void => {
if (
ts.isStringLiteralLikeNode(node) ||
@ -324,23 +326,21 @@ export function collectTypeScriptCommentRanges(
node.kind === ts.SyntaxKind.TemplateTail ||
node.kind === ts.SyntaxKind.JsxText
) {
literals.push({ pos: node.getStart(sourceFile), end: node.end });
literals.push({
kind: node.kind,
pos: node.kind === ts.SyntaxKind.JsxText ? node.pos : node.getStart(sourceFile),
end: node.end,
});
return;
}
node.forEachChild(visit);
};
visit(sourceFile);
const scanner = ts.createScanner(false);
const comments: ts.CommentRange[] = [];
const scanGap = (start: number, end: number): void => {
scanner.setText(source, start, end - start);
for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFile; kind = scanner.scan()) {
if (
kind === ts.SyntaxKind.SingleLineCommentTrivia ||
kind === ts.SyntaxKind.MultiLineCommentTrivia
) {
comments.push({ kind, pos: scanner.getTokenStart(), end: scanner.getTokenEnd() });
}
onToken(kind, scanner.getTokenStart(), scanner.getTokenEnd());
}
};
let end = 0;
@ -348,9 +348,24 @@ export function collectTypeScriptCommentRanges(
// Literal spans come from the parser so regexp and template text cannot invent comments.
for (const literal of literals.toSorted((left, right) => left.pos - right.pos)) {
scanGap(end, literal.pos);
onToken(literal.kind, literal.pos, literal.end);
end = literal.end;
}
scanGap(end, source.length);
}
export function collectTypeScriptCommentRanges(
sourceFile: ts.SourceFile,
): Iterable<ts.CommentRange> {
const comments: ts.CommentRange[] = [];
walkTypeScriptTokens(sourceFile, (kind, pos, end) => {
if (
kind === ts.SyntaxKind.SingleLineCommentTrivia ||
kind === ts.SyntaxKind.MultiLineCommentTrivia
) {
comments.push({ kind, pos, end });
}
});
return comments;
}

View file

@ -0,0 +1,143 @@
import { execFileSync } from "node:child_process";
import { lstatSync, readFileSync } from "node:fs";
import path from "node:path";
import * as ts from "typescript/unstable/ast";
import { resolveMergeHeadDiffBase } from "./merge-head-diff-base.mjs";
import { createNativeTypeScriptParser } from "./native-typescript.mts";
import { walkTypeScriptTokens } from "./ts-guard-utils.mts";
type SourceChange = { path: string; before: string; after: string };
const TYPESCRIPT_PATH = /\.(?:ts|tsx|mts|cts)$/u;
/** Returns a syntax signature, or undefined when trivia could carry compiler semantics. */
function syntaxSignature(sourceFile: ts.SourceFile) {
const source = sourceFile.getFullText();
// Each token keeps its preceding gap: "" adjacent, " " same-line trivia, "\n" line terminator.
const tokens: Array<[string, string]> = [];
// Tagged JSDoc can reference symbols (`{@link Foo}`, `@see Foo`), and its surrounding trivia
// decides leading/trailing attachment, so that whole trivia run must stay byte-identical.
const docs: Array<[number, string]> = [];
let gap = "";
let trivia = "";
let tagged = false;
let end = 0;
let valid = true;
walkTypeScriptTokens(sourceFile, (kind, pos, tokenEnd) => {
// The scanner may skip unsupported trivia (for example a hashbang).
if (pos !== end || tokenEnd < pos) {
valid = false;
}
end = tokenEnd;
const text = source.slice(pos, end);
if (
kind === ts.SyntaxKind.SingleLineCommentTrivia ||
kind === ts.SyntaxKind.MultiLineCommentTrivia
) {
// TypeScript matches pragma names case-insensitively.
if (/@ts-|@jsx/iu.test(text) || text.startsWith("///")) {
valid = false;
} else if (text.startsWith("/**") && text.includes("@")) {
tagged = true;
}
} else if (kind !== ts.SyntaxKind.WhitespaceTrivia && kind !== ts.SyntaxKind.NewLineTrivia) {
if (kind <= ts.SyntaxKind.NonTextFileMarkerTrivia) {
valid = false;
}
if (tagged) {
docs.push([tokens.length, trivia]);
}
tokens.push([gap, text]);
gap = "";
trivia = "";
tagged = false;
return;
}
trivia += text;
// Adjacency matters too: the parser rescans `>` together with an adjacent `=` or `>`.
gap = gap === "\n" || /[\r\n\u2028\u2029]/u.test(text) ? "\n" : " ";
});
if (tagged) {
docs.push([tokens.length, trivia]);
}
return valid && end === source.length ? JSON.stringify([tokens, docs]) : undefined;
}
/** Compare syntax in one native snapshot, preserving literal bytes, adjacency, and ASI boundaries. */
export function findTypecheckInertSources(changes: readonly SourceChange[]): string[] {
const candidates = changes.filter((change) => TYPESCRIPT_PATH.test(change.path));
try {
using parser = createNativeTypeScriptParser();
const sources = candidates.flatMap((change, index) =>
[change.before, change.after].map((text, version) => ({
fileName: `.typecheck-inert/${index}/${version}/${path.basename(change.path)}`,
text,
})),
);
const parsed = parser.parseSourceFiles(sources);
return candidates.flatMap((change, index) => {
const offset = index * 2;
if (
parser.getSyntacticDiagnostics(sources[offset]!.fileName).length ||
parser.getSyntacticDiagnostics(sources[offset + 1]!.fileName).length
) {
return [];
}
const before = syntaxSignature(parsed[offset]!);
return before !== undefined && before === syntaxSignature(parsed[offset + 1]!)
? [change.path]
: [];
});
} catch (error) {
// Keep full typechecks, but make an unavailable classifier visible (for example under Bun).
console.error(
`[check:changed] comment-only classification unavailable; typecheck lanes retained: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/** Only existing regular files with a regular merge-base blob can be omitted. */
export function findTypecheckInertPaths({
paths,
base,
cwd = process.cwd(),
}: {
paths: readonly string[];
base: string;
cwd?: string;
}): string[] {
const git = (args: string[]) =>
execFileSync("git", args, {
cwd,
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 64 * 1024 * 1024,
});
// Lossy decoding would map different invalid bytes to the same replacement text.
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
try {
const resolvedBase = resolveMergeHeadDiffBase({ base, head: "HEAD", cwd });
const mergeBase = git(["merge-base", resolvedBase, "HEAD"]).toString().trim();
const changes: SourceChange[] = [];
for (const file of paths.filter((candidate) => TYPESCRIPT_PATH.test(candidate))) {
try {
if (!lstatSync(path.resolve(cwd, file)).isFile()) {
continue;
}
const entry = git(["ls-tree", "-z", mergeBase, "--", `:(literal)${file}`]).toString();
if (!/^100(?:644|755) blob [0-9a-f]+\t/u.test(entry)) {
continue;
}
changes.push({
path: file,
before: utf8.decode(git(["show", `${mergeBase}:${file}`])),
after: utf8.decode(readFileSync(path.resolve(cwd, file))),
});
} catch {
// Missing/deleted paths and unreadable blobs retain their normal lanes.
}
}
return findTypecheckInertSources(changes);
} catch {
return [];
}
}

View file

@ -41,6 +41,7 @@ import {
delegationFailedBeforeRunning,
} from "../../scripts/check-changed.mts";
import { resolveOxfmtInvocation } from "../../scripts/format-docs.mts";
import { findTypecheckInertPaths } from "../../scripts/lib/typecheck-inert.mts";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import {
cleanupTempDirs,
@ -2754,6 +2755,77 @@ describe("scripts/changed-lanes", () => {
);
});
it.each([false, true])(
"retains non-typecheck gates with inert core edits (mixed=%s)",
(mixed) => {
const inert = "src/gateway/control-plane-rate-limit.ts";
const remaining = mixed ? ["src/gateway/server.ts"] : ["docs/gateway/authentication.md"];
const result = detectChangedLanes([inert, ...remaining]);
const plan = createChangedCheckPlan(result, {
typecheckResult: detectChangedLanesForPaths({ paths: remaining, base: "HEAD" }),
env: {},
});
const names = plan.commands.map((command) => command.name);
expect(names.includes("core tsgo graph boundary")).toBe(mixed);
expect(names.some((name) => name.startsWith("typecheck"))).toBe(mixed);
expect(plan.commands.some((command) => command.args[0]?.startsWith("tsgo:"))).toBe(mixed);
expect(names.some((name) => name.startsWith("lint core"))).toBe(true);
expect(names).toEqual(
expect.arrayContaining([
"format changed files",
"line-cap growth ratchet",
"max-lines suppression ratchet",
"assertion SAFETY comment ratchet",
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
]),
);
},
);
it.each([
["package.json", true],
["CHANGELOG.md", false],
])("keeps unguarded release metadata typechecks for %s: %s", (companion, expected) => {
const result = detectChangedLanes(["src/gateway/control-plane-rate-limit.ts", companion]);
const plan = createChangedCheckPlan(result, {
typecheckResult: detectChangedLanes([companion]),
env: {},
});
expect(plan.commands.some((command) => command.name.startsWith("typecheck"))).toBe(expected);
});
it("compares regular working files with the merge base, excluding path lifecycle changes", () => {
const dir = renameTempDirs.make("openclaw-inert-paths-");
git(dir, ["init", "-q"]);
for (const file of ["kept.ts", "renamed.ts", "deleted.ts", "linked.ts"]) {
writeRepoFile(dir, file, "// old\nexport const x = 1;\n");
}
symlinkSync("kept.ts", path.join(dir, "was-link.ts"));
const invalidUtf8 = (byte: number) =>
Buffer.concat([
Buffer.from("// "),
Buffer.from([byte]),
Buffer.from("\nexport const x = 1;\n"),
]);
writeFileSync(path.join(dir, "bytes.ts"), invalidUtf8(0xff));
commitAll(dir, "base");
const base = git(dir, ["rev-parse", "HEAD"]);
writeRepoFile(dir, "kept.ts", "// branch\nexport const x = 1;\n");
commitAll(dir, "branch comment");
writeRepoFile(dir, "kept.ts", "// working tree\nexport const x = 1;\n");
renameSync(path.join(dir, "renamed.ts"), path.join(dir, "moved.ts"));
unlinkSync(path.join(dir, "deleted.ts"));
unlinkSync(path.join(dir, "linked.ts"));
symlinkSync("kept.ts", path.join(dir, "linked.ts"));
unlinkSync(path.join(dir, "was-link.ts"));
writeRepoFile(dir, "was-link.ts", "// old\nexport const x = 1;\n");
writeFileSync(path.join(dir, "bytes.ts"), invalidUtf8(0xfe));
writeRepoFile(dir, "added.ts", "// old\nexport const x = 1;\n");
const paths = listChangedPathsFromGit({ base, cwd: dir });
expect(findTypecheckInertPaths({ paths, base, cwd: dir })).toEqual(["kept.ts"]);
expect(findTypecheckInertPaths({ paths, base: "missing-ref", cwd: dir })).toEqual([]);
});
it("runs deprecation hygiene checks for outcome-changing paths and all lanes", () => {
expect(
shouldRunDeprecationHygieneChecks([

View file

@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { findTypecheckInertSources } from "../../scripts/lib/typecheck-inert.mts";
// One snapshot amortizes native parser startup across syntax and ASI controls.
describe("typecheck-inert TypeScript changes", () => {
it("ignores only ordinary trivia with unchanged token and line-break boundaries", () => {
const cases: Array<[string, string, string, boolean]> = [
["comment.ts", "// old\nlet x = 1;", "// new\nlet x = 1;", true],
["add.ts", "let x = 1;\nx++;", "let x = 1;\n// new\nx++;", true],
["remove.ts", "let x = 1;\n// old\nx++;", "let x = 1;\nx++;", true],
["indent.mts", "function f() {\n return 1;\n}", "function f() {\n return 1;\n}", true],
["spaces.cts", "let x = 1;", "let x = 1;", true],
[
"prose.d.ts",
"/** old */\ndeclare const x: number;",
"/** new */\ndeclare const x: number;",
true,
],
["ambient.d.mts", "// old\nexport {};", "// new\nexport {};", true],
[
"add-prose-doc.ts",
"const a = 1;\nexport const x = a;",
"const a = 1;\n/** Added prose. */\nexport const x = a;",
true,
],
[
"link.ts",
'import type { Foo } from "./foo";\n/** Uses {@link Foo}. */\nexport const x = 1;',
'import type { Foo } from "./foo";\n/** Uses Foo. */\nexport const x = 1;',
false,
],
[
"see.ts",
'import type { Foo } from "./foo";\n/** Old. @see Foo */\nexport const x = 1;',
'import type { Foo } from "./foo";\n/** New. @see Foo */\nexport const x = 1;',
false,
],
[
"attached-doc.ts",
'import type { Foo } from "./foo";\n/** {@link Foo} */\nexport const x = 1;',
'import type { Foo } from "./foo"; /** {@link Foo} */\nexport const x = 1;',
false,
],
[
"tagged-doc-neighbor.ts",
"/** @deprecated */\nexport const x = 1;\n// old\nexport const y = 2;",
"/** @deprecated */\nexport const x = 1;\n// new\nexport const y = 2;",
true,
],
[
"moved-doc.ts",
"/** @deprecated */\nexport const x = 1;\nexport const y = 2;",
"export const x = 1;\n/** @deprecated */\nexport const y = 2;",
false,
],
["ambient.d.cts", "// old\nexport {};", "// new\nexport {};", true],
["return.ts", "function f() { return x; }", "function f() { return\nx; }", false],
[
"block.ts",
"function f() { return /* a */ x; }",
"function f() { return /*\n*/ x; }",
false,
],
[
"unicode.ts",
"function f() { return /* a */ x; }",
"function f() { return /*\u2028*/ x; }",
false,
],
[
"rescan.ts",
"declare let a: any, b: any, c: any;\nexport const r = a < b >= c;",
"declare let a: any, b: any, c: any;\nexport const r = a < b > = c;",
false,
],
["adjacent.ts", "let x = a+b;", "let x = a + b;", false],
["adjacent-comment.ts", "let x = a+b;", "let x = a/* c */+b;", false],
["string.ts", 'let x = "a";', 'let x = "b";', false],
["template.ts", "let x = `a${1}b${2}c`;", "let x = `a${1} b${2}c`;", false],
["template-tail.ts", "let x = `a${1}b`;", "let x = `a${1} b`;", false],
["regex.ts", "let x = /a/;", "let x = /b/;", false],
["jsx.tsx", "let x = <p> text </p>;", "let x = <p> text </p>;", false],
["jsx-space.tsx", "let x = <p> </p>;", "let x = <p> </p>;", false],
["keyword.ts", "let x = 1;", "const x = 1;", false],
["add-directive.ts", "\nx();", "// @ts-expect-error\nx();", false],
["remove-directive.ts", "// @ts-expect-error\nx();", "\nx();", false],
["existing-directive.ts", "// @ts-ignore\nx(); // old", "// @ts-ignore\nx(); // new", false],
["jsx-directive.tsx", "/** @jsx h */\nx(); // old", "/** @jsx h */\nx(); // new", false],
[
"upper-directive.ts",
"// @TS-NOCHECK\nconst x: string = 1;",
"// ok\nconst x: string = 1;",
false,
],
["upper-jsx.tsx", "/** @JSX h */\nx(); // old", "/** @JSX h */\nx(); // new", false],
[
"reference.ts",
'/// <reference path="a.ts" />\n// old',
'/// <reference path="a.ts" />\n// new',
false,
],
["invalid-before.ts", "let = ; // old", "let x = 1; // new", false],
["invalid-after.ts", "let x = 1; // old", "let = ; // new", false],
["invalid-both.ts", "let = ; // old", "let = ; // new", false],
[
"hashbang.ts",
"#!/usr/bin/env node\n// old\nx();",
"#!/usr/bin/env node\n// new\nx();",
false,
],
...["js", "jsx", "mjs", "cjs"].map((ext): [string, string, string, boolean] => [
`javascript.${ext}`,
"/** old */\nlet x = 1;",
"/** new */\nlet x = 1;",
false,
]),
[
"literal-comments.ts",
'const x = "@ts-ignore"; const y = /\\/\\//; const z = `/* raw */${1 /* old */}`;',
'const x = "@ts-ignore"; const y = /\\/\\//; const z = `/* raw */${1 /* new */}`;',
true,
],
["crlf.ts", "// old\r\nlet x = 1;", "// new\nlet x = 1;", true],
];
const inert = findTypecheckInertSources(
cases.map(([path, before, after]) => ({ path, before, after })),
);
expect(inert).toEqual(cases.filter((entry) => entry[3]).map(([path]) => path));
});
});