diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67524d9e..4110c195 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install @@ -28,23 +28,23 @@ jobs: - name: Parity bench run: pnpm -F @tsslint/compat-eslint bench - # Vendored @typescript-eslint/scope-manager test suite. - - name: Upstream scope-manager - run: pnpm -F @tsslint/compat-eslint test:upstream - # Predicate coverage matrix for AST_NODE_TYPES. - name: Predicate coverage run: pnpm -F @tsslint/compat-eslint test:predicate-coverage - # JSX type-aware parity vs ESLint Linter on eslint-plugin-react-x. - - name: JSX react-x parity - run: pnpm -F @tsslint/compat-eslint test:jsx-react-x - # Lazy ESTree shape parity (every .ts under packages/) + identity # invariants + smoke under visitorKeys traversal. - name: Lazy ESTree parity run: node packages/compat-eslint/test/lazy-estree.test.js + # Regression tests for lazy ESTree review findings. + - name: Lazy ESTree review bugs + run: node packages/compat-eslint/test/lazy-estree-review-bugs.test.js + + # Predicate vs materializer agreement. + - name: Predicate materializer consistency + run: node packages/compat-eslint/test/predicate-materializer-consistency.test.js + # Integration: selector dispatch + CPA fallback + per-rule error # isolation through the full convertRule pipeline. - name: Compat pipeline integration @@ -58,31 +58,26 @@ jobs: - name: TS-AST scan run: node packages/compat-eslint/test/ts-ast-scan.test.js - # Core: type-aware rule probe (foundation for layer 1 cache). - - name: Core probe - run: node packages/core/test/probe.test.js - - # Core: BuilderProgram POC for layer 2 cache invalidation. - - name: Core BuilderProgram POC - run: node packages/core/test/builder-program-poc.test.js - - # Core: skip-rules option + hasFixForDiagnostic API (the surface - # the CLI cache layer uses). - - name: Core skip-rules - run: node packages/core/test/skip-rules.test.js - # CLI: cache file load / save / atomic write / version key. - name: CLI cache module run: node packages/cli/test/cache.test.js - # CLI: cache-flow — layer 1 cache integration with the linter. + # CLI: cache-flow — per-file cache integration with the linter. - name: CLI cache-flow run: node packages/cli/test/cache-flow.test.js + # CLI: dependency invalidation against a stubbed TypeScript API. + - name: CLI invalidation + run: node packages/cli/test/invalidation.test.js + # CLI: end-to-end integration via subprocess on a temp fixture. - name: CLI integration run: node packages/cli/test/integration.test.js - # CLI: layer 2 cross-session affected-file diff. - - name: CLI incremental state - run: node packages/cli/test/incremental-state.test.js + # CLI: diagnostic rendering. + - name: CLI render + run: node packages/cli/test/render.test.js + + # Repo dogfood. + - name: Lint + run: pnpm run lint --force diff --git a/.gitignore b/.gitignore index 0d467c28..e9192e3c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,6 @@ packages/*/index.js packages/*/lib/**/*.js !packages/compat-eslint/lib/code-path-analysis/*.js !packages/compat-eslint/lib/code-path-analysis/*.d.ts +!tools/estree-oracle/index.d.ts packages/*/test/**/*.js .tsslint/ -packages/compat-eslint/test/upstream-shim.js diff --git a/README.md b/README.md index b0792b88..026cc14b 100644 --- a/README.md +++ b/README.md @@ -11,41 +11,13 @@ Ask DeepWiki

-A linter that runs as a `tsserver` plugin. It reuses the TypeChecker your editor already has — no second process, no AST conversion, no duplicated type-checking. +A linter for TypeScript 7. Rules are plain functions over the TypeScript AST, with the TypeScript 7 checker available for type information. -Zero built-in rules. Rules are plain functions over the TypeScript compiler API. - -## Why? - -ESLint runs in its own process and builds its own type information. On large projects this makes "Auto Fix on Save" slow. - -TSSLint piggybacks on `tsserver`. Diagnostics show up in the same path TypeScript errors do, using the same `Program` instance. - -``` - Traditional TSSLint - ─────────── ─────── - - ┌─────┐ ┌─────┐ - │ IDE │ │ IDE │ - └──┬──┘ └──┬──┘ - │ │ - ┌───┴────┐ ▼ - ▼ ▼ ┌─────────────────┐ - ┌──────┐ ┌──────┐ │ tsserver │ - │ ts- │ │linter│ │ ┌───────────┐ │ - │server│ │ │ │ │TypeChecker│ │ - │ │ │ │ │ └─────┬─────┘ │ - │ Type │ │ Type │ │ │ │ - │ Chk. │ │ Chk. │ │ ┌─────▼─────┐ │ - └──────┘ └──────┘ │ │ TSSLint │ │ - │ └───────────┘ │ - ✗ two type-checkers └─────────────────┘ - two parses ✓ one shared pass -``` +Zero built-in rules. ## How it compares -TSLint (TS-AST, deprecated 2019) → ESLint took over via `typescript-eslint` → TSSLint revives the in-process TS-AST approach as a `tsserver` plugin (2023). +TSLint (TS-AST, deprecated 2019) → ESLint took over via `typescript-eslint` → TSSLint revives the TS-AST approach (2023), on the TypeScript 7 API since v4. ``` 2013 2019 2023 @@ -57,24 +29,24 @@ TSLint (TS-AST, deprecated 2019) → ESLint took over via `typescript-eslint` ESLint ─────●━━━━━━━━━━━━━━━━╲━━━━━━━━━━━━━━━━━━━▶ (active) ╲ │ ╲ │ - TSSLint ╲────────────●━━▶ (tsserver plugin, - revives TS-AST) + TSSLint ╲────────────●━━▶ (revives TS-AST, + TypeScript 7 since v4) ``` | | ESLint | TSLint | Oxlint | TSSLint | |---|---|---|---|---| -| Runtime | Node, separate process | Node, separate process | Rust, separate process | Node, in `tsserver` | +| Runtime | Node, separate process | Node, separate process | Rust, separate process | Node + TypeScript 7 | | AST | ESTree | TS AST | Native Rust AST | TS AST | -| Type-aware rules | Yes (its own `Program`) | Yes (its own `Program`) | Yes (via `tsgolint`, alpha) | Yes (shared `TypeChecker`) | -| Built-in rules | Many | Deprecated | Subset of ESLint (+ JS plugins, alpha) | Zero (imports ESLint / TSLint / TSL) | +| Type-aware rules | Yes (its own `Program`) | Yes (its own `Program`) | Yes (via `tsgolint`, alpha) | Yes (TypeScript 7 checker) | +| Built-in rules | Many | Deprecated | Subset of ESLint (+ JS plugins, alpha) | Zero (imports ESLint rules) | | Status | Active standard | Deprecated 2019 | Active | Active | -**Pick by need.** Largest ecosystem → ESLint. Fastest standalone runtime → Oxlint. Type-aware without duplicate type-checking → TSSLint. +**Pick by need.** Largest ecosystem → ESLint. Fastest standalone runtime → Oxlint. Rules on the TypeScript 7 AST and checker → TSSLint. ## Setup ```bash -npm install @tsslint/config --save-dev +npm install @tsslint/cli @tsslint/config --save-dev ``` `tsslint.config.ts`: @@ -89,41 +61,27 @@ export default defineConfig({ }); ``` -**VSCode**: install [the extension](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-tsslint). - -**Other editors**: install the plugin and register it in `tsconfig.json`: - -```bash -npm install @tsslint/typescript-plugin --save-dev -``` +Then run `npx tsslint --project tsconfig.json` (see [CLI](#cli)). -```json -{ - "compilerOptions": { - "plugins": [{ "name": "@tsslint/typescript-plugin" }] - } -} -``` +There is no editor integration in v4: TypeScript 7 has no tsserver plugin API. ## Writing rules -A rule is a function. It receives the TypeScript module, the current `Program`, the `SourceFile`, and a `report()` callback. +A rule is a function. It receives the `typescript/ast` module, the current `Program` and `Checker` (from `typescript/sync`), the `SourceFile`, and a `report()` callback. ```ts import { defineRule } from '@tsslint/config'; export default defineRule(({ typescript: ts, file, report }) => { - ts.forEachChild(file, function visit(node) { + file.forEachChild(function visit(node): void { if (node.kind === ts.SyntaxKind.DebuggerStatement) { report('Debugger statement is not allowed.', node.getStart(file), node.getEnd()); } - ts.forEachChild(node, visit); + node.forEachChild(visit); }); }); ``` -Touch `program` only when you need type information — rules that don't are cached aggressively (see [Caching](#caching)). - ### Severity, fixes, refactors `report()` returns a chainable reporter: @@ -134,12 +92,12 @@ report('No console.', node.getStart(file), node.getEnd()) .withDeprecated() // strikethrough .withUnnecessary() // faded .withFix('Remove call', () => [ - { fileName: file.fileName, textChanges: [{ span: { start, length }, newText: '' }] }, + { pos: node.getStart(file), end: node.getEnd(), newText: '' }, ]) .withRefactor('Wrap in if (DEBUG)', () => [/* ... */]); ``` -`withFix` runs automatically as a quick fix; `withRefactor` shows up under the editor's refactor menu (user-initiated). +`withFix` is applied by `tsslint --fix`; `withRefactor` registers a user-initiated edit for hosts that expose refactors. ### Real-world example @@ -163,26 +121,25 @@ defineConfig({ ### Caching -Diagnostics are cached on disk under `os.tmpdir()/tsslint-cache/` in two layers, picked per rule: - -- **Layer 1** — invalidated by the linted file's mtime. Used for rules that don't read `ctx.program` (purely syntactic). -- **Layer 2** — invalidated by TypeScript's `BuilderProgram` affected-file diff (transitive, includes ambient `.d.ts`). Used for rules that touch `ctx.program`. The first time a rule reads `ctx.program` it's classified type-aware and stays type-aware across sessions. +The CLI caches each file's diagnostics on disk under `os.tmpdir()/tsslint-cache/`, and re-lints a file only when its result may have changed, the way `tsc --incremental` decides what to re-check: -A diagnostic whose correctness depends on inputs neither layer tracks — external resources, env vars, sibling files the rule reads directly via `fs` — should opt out per-diagnostic via `.withoutCache()` on the reporter. The diagnostic still surfaces on the current run; it just isn't written to disk, so the next warm hit on this file won't replay it (the rule has to re-run to surface it again). +- the file itself changed (mtime or size), or +- a file it imports or references changed its declaration signature (its `.d.ts` emit). Editing a function body re-lints only that file; changing an exported type re-lints only the importers that use a name the change reaches, and keeps going through files whose own signature changes as a result (e.g. re-exports), or +- a rule read another file beyond its declarations while linting it — a declaration node such as a called function's body, the file's source, its diagnostics, or a checker query by file and position — and that file's text changed. -For diagnostics that depend on cross-file types, prefer reading `ctx.program` once instead — that re-classifies the rule type-aware and layer 2 handles invalidation properly. +Everything is re-linted when a change can affect any file: compiler options or a tsconfig file, the content mappers (including the nearest lockfile above each, so upgrading a mapper's dependencies counts), `tsslint.config.ts` or any module it imports, files added to or removed from the program, files under `node_modules`, a changed declaration signature of a script without imports or exports or of a UMD module (`export as namespace`), or a change of what a `declare global` / `declare module '...'` block declares, including through the imports and declarations the block names. A module's other exports only re-lint its importers. -Pass `--force` to the CLI to ignore the cache. `--list-rules` prints each rule's classification (type-aware vs syntactic) after the run. +Rules must only depend on the program they are given (`ctx.file`, `ctx.program`, `ctx.checker`). Anything outside it — files read directly via `fs`, environment variables, network resources — is not tracked; pass `--force` to re-lint every file. ### Debugging -Every `report()` captures a stack trace. The diagnostic carries a "Related Information" link back to the exact line in your rule that triggered it — ⌘-click in the editor to jump there: +Every `report()` captures a stack trace. The diagnostic carries a "Related Information" entry pointing at the exact line in your rule that triggered it: ``` src/index.ts:3:1 3 │ debugger; │ ~~~~~~~~~ Debugger statement is not allowed. (tsslint) - │ ↳ rules/no-debugger.ts:5:7 ⌘-click to open + │ ↳ rules/no-debugger.ts:5:7 ``` ## CLI @@ -202,11 +159,6 @@ Flags: | Flag | | |---|---| | `--project ` | TypeScript projects to lint | -| `--vue-project ` | Vue projects | -| `--vue-vine-project ` | Vue Vine projects | -| `--mdx-project ` | MDX projects | -| `--astro-project ` | Astro projects | -| `--ts-macro-project ` | TS Macro projects | | `--filter ` | Restrict to matching files | | `--fix` | Apply fixes | | `--force` | Ignore cache | @@ -217,36 +169,24 @@ TSSLint produces diagnostics and edits — it does not format. Run dprint or Pre ## Framework support -The `--*-project` flags wire in [Volar](https://volarjs.dev/) language plugins so framework files (Vue SFCs, MDX, Astro components, etc.) are virtualized as TypeScript before linting. Anything `tsserver` can see, TSSLint can lint. +Since v4, framework files (`.vue`, `.astro`, `.mdx`, `.svelte`, …) are linted through TypeScript 7.1's **content mapper** — the same mechanism `tsc` uses — instead of Volar language plugins. Declare the mappers in `tsconfig.json`; TSSLint picks them up with the project: -``` - .vue ──┐ - .mdx ──┤ ┌──────────────┐ ┌──────────────────┐ - .astro──┼───▶│ Framework │───▶│ tsserver │───▶ diagnostics - .ts ──┘ │ adapters │ │ │ in editor - │ │ │ TypeChecker │ - │ ─▶ virtual │ │ + │ - │ TS file │ │ TSSLint plugin │ - └──────────────┘ └──────────────────┘ +```jsonc +{ + "contentMappers": [ + { "package": "@vue/content-mapper", "extensions": [".vue"] } + ] +} ``` -Each flag resolves the language plugin from your project's `node_modules`, so you must install the corresponding package: - -| Flag | Required package(s) | -|---|---| -| `--vue-project` | `@vue/language-core` or `vue-tsc` | -| `--vue-vine-project` | `@vue-vine/language-service` or `vue-vine-tsc` | -| `--mdx-project` | `@mdx-js/language-service` | -| `--astro-project` | `@astrojs/ts-plugin` | -| `--ts-macro-project` | `@ts-macro/language-plugin` or `@ts-macro/tsc` | +Rules run against the transformed TypeScript, and diagnostics are mapped back to the original file through the mapper's span map. Diagnostics that only cover generated code are dropped, and `--fix` only applies fixes whose edits all map exactly onto original text. -## Importing ESLint, TSLint, or TSL rules +The v3 flags `--vue-project`, `--vue-vine-project`, `--mdx-project`, `--astro-project`, and `--ts-macro-project` are gone; the mapper configuration in `tsconfig.json` replaces them. See [docs/migration-v4.md](docs/migration-v4.md). -### ESLint +## Importing ESLint rules ```bash npm install @tsslint/compat-eslint --save-dev -npm install @typescript-eslint/eslint-plugin --save-dev # for @typescript-eslint/* rules npx tsslint-docgen # generates JSDoc for IDE autocomplete ``` @@ -259,43 +199,13 @@ export default defineConfig({ rules: { ...await importESLintRules({ 'no-unused-vars': true, - '@typescript-eslint/no-explicit-any': 'warn', + 'eqeqeq': 'warn', }), }, }); ``` -### TSLint - -```bash -npm install tslint --save-dev # required for built-in rules -npx tsslint-docgen -``` - -```ts -import { defineConfig, importTSLintRules } from '@tsslint/config'; - -export default defineConfig({ - rules: await importTSLintRules({ - 'no-console': true, - }), -}); -``` - -### TSL - -```bash -npm install tsl --save-dev -``` - -```ts -import { defineConfig, fromTSLRules } from '@tsslint/config'; -import { core } from 'tsl'; - -export default defineConfig({ - rules: fromTSLRules(core.all()), -}); -``` +`@typescript-eslint/eslint-plugin` does not load with `typescript@7` yet (its dependencies read the classic `typescript` API at load time), so `@typescript-eslint/*` rules are unavailable until typescript-eslint supports TypeScript 7. ## Plugins @@ -307,9 +217,8 @@ import { createIgnorePlugin, createCategoryPlugin, createDiagnosticsPlugin, - isCLI, } from '@tsslint/config'; -import ts from 'typescript'; +import { DiagnosticCategory } from 'typescript/sync'; export default defineConfig({ rules: { /* ... */ }, @@ -319,13 +228,11 @@ export default defineConfig({ // Override severity by rule-id pattern createCategoryPlugin({ - 'style/*': ts.DiagnosticCategory.Warning, + 'style/*': DiagnosticCategory.Warning, }), // Forward TypeScript's own diagnostics through the same pipeline. - // Guard with `isCLI()` — tsserver already surfaces these in editors, - // so emitting them again from the plugin would double-report there. - ...(isCLI() ? [createDiagnosticsPlugin('semantic')] : []), + createDiagnosticsPlugin('semantic'), ], }); ``` @@ -334,9 +241,8 @@ Build your own with the `Plugin` type from `@tsslint/types`. ## Requirements -- Node.js **22.6.0+** (uses `--experimental-strip-types` to load `tsslint.config.ts` directly — no transpile step) -- Any TypeScript version with Language Service Plugin support -- Not compatible with `typescript-go` (v7), which does not yet support Language Service Plugins +- Node.js **22.15.0+** (`tsslint.config.ts` is loaded with `--experimental-strip-types` — no transpile step) +- TypeScript 7 comes bundled as a dependency, separate from the `typescript` your project compiles with. TypeScript 5/6 are not supported; see [docs/migration-v4.md](docs/migration-v4.md) ## License diff --git a/docs/migration-v4.md b/docs/migration-v4.md new file mode 100644 index 00000000..00a2d774 --- /dev/null +++ b/docs/migration-v4.md @@ -0,0 +1,143 @@ +# Migrating to TSSLint v4 + +v4 runs on TypeScript 7 only. Rules see the TypeScript 7 AST (`typescript/ast`) +and the TypeScript 7 checker (`typescript/sync`), and framework files are linted +through TypeScript's own content mapper instead of Volar language plugins. + +## Breaking changes + +### 1. TypeScript 7 is bundled as a dependency + +TSSLint depends on TypeScript 7 directly, so there is nothing extra to +install, and it does not use the `typescript` your project compiles with. +There is no TypeScript 5/6 code path and no compatibility layer. + +- Node.js **22.15.0+** is required (the `typescript` 7 package needs 22.12, and the + CLI cache tracks the modules your config imports with `Module.registerHooks`, + added in 22.15). +- Diagnostics come from the TypeScript 7 checker. Output can differ from + TypeScript 5/6 in edge cases; that difference is the engine, not a TSSLint + regression. + +### 2. Rule context + +| v3 | v4 | +|---|---| +| `typescript` — the classic `typescript` module | `typescript` — the `typescript/ast` module (`SyntaxKind`, `is*` guards, AST utilities) | +| `program` — `ts.Program` | `program` — `Program` from `typescript/sync` | +| `program.getTypeChecker()` | `checker` — `Checker` from `typescript/sync` | +| `ts.forEachChild(node, cb)` | `node.forEachChild(cb)` | +| `ts.sys`, `ts.findConfigFile`, … | use `node:fs` / `node:path` | + +```ts +import { defineRule } from '@tsslint/config'; + +export default defineRule(({ typescript: ts, file, report }) => { + file.forEachChild(function visit(node): void { + if (node.kind === ts.SyntaxKind.DebuggerStatement) { + report('Debugger statement is not allowed.', node.getStart(file), node.getEnd()); + } + node.forEachChild(visit); + }); +}); +``` + +Results use TypeScript 7.1's own types. A rule report is a `Diagnostic` from +`typescript/sync` (`pos`, `end`, `text`) plus a `ruleId`, and `withFix` / +`withRefactor` callbacks return `TextEdit[]` (`{ pos, end, newText }`) for the +reported file. Plugin `resolveDiagnostics` and `resolveCodeFixes` receive the +lint target (`{ program, checker, file }`) instead of the file. + +### 3. Removed packages and importers + +- `@tsslint/typescript-plugin` and the VS Code extension are removed. TypeScript 7 + has no tsserver plugin API. +- `importTSLintRules` and `fromTSLRules` are removed. Both depend on the classic + `typescript` API. + +### 4. Framework flags are replaced by `contentMappers` + +| v3 | v4 | +|---|---| +| `tsslint --project tsconfig.json --vue-project tsconfig.json` | declare `contentMappers` in `tsconfig.json`, then `tsslint --project tsconfig.json` | +| `--vue-vine-project`, `--mdx-project`, `--astro-project`, `--ts-macro-project` | same — mapper entries in `tsconfig.json` | + +See [Framework projects](#framework-projects). + +### 5. Caching + +The cache format is bumped, so the first v4 run re-lints everything. + +Rules are no longer split into syntactic and type-aware. Each file's result is +reused until the file changes or a file it depends on changes its declaration +signature, like `tsc --incremental`, or the text of a file a rule read beyond +its declarations changes. Changes that can affect any file (compiler options, +config and rule modules, the declarations of globals) re-lint everything. See +[Caching](../README.md#caching). `--list-rules` is removed, since there is no +classification left to print. + +`Reporter.withoutCache()` is removed. Rules must only depend on the program; +inputs outside it (`fs`, environment variables) are not tracked, and `--force` +re-lints every file. + +### 6. ESLint interop + +`importESLintRules` still works for ESLint core rules and for plugins that +don't import `typescript`. + +`@typescript-eslint/eslint-plugin` does not load with `typescript@7`: its +dependencies read the classic `typescript` API at module load. Remove +`@typescript-eslint/*` rules from the config until typescript-eslint supports +TypeScript 7. + +### 7. `@tsslint/core` API + +Only relevant if you embed the linter directly: + +- `createLinter(ctx, rootDir, config, …)` takes + `ctx: { typescript: typeof import('typescript/ast') }`. +- `linter.lint(target, …)` takes `target: { program, checker, file }` instead of a + file name. +- `applyTextEdits(text, edits)` works on strings. +- `linter.getTouchedFiles(fileName)` lists the other files the rules read beyond + their declarations during the last `lint` of the file. + +## Framework projects + +Framework files are linted through TypeScript 7.1's content mapper. Add the +mapper for your framework to `tsconfig.json`: + +```jsonc +{ + "compilerOptions": { }, + "contentMappers": [ + { "package": "@vue/content-mapper", "extensions": [".vue"], "options": { } } + ], + "include": ["src"] +} +``` + +`package` is resolved through `node_modules`. The mapper package declares how it +runs in its own `package.json`: + +```jsonc +{ + "typescript": { + "contentMapper": { + "exec": ["node", "server.js"], + "dynamicConfig": true + } + } +} +``` + +What this means for rules: + +- Rules run on the transformed TypeScript (`file.text`); `file.fileName` is the + original file. Report positions in the transformed text. +- Diagnostics are mapped back to the original file through the mapper's span map. + Diagnostics that only cover generated code are dropped. +- `--fix` applies a fix only when every edit in it maps exactly onto original + text. Other fixes are skipped. +- TSSLint runs mappers as part of loading the project; there is no equivalent of + `tsc --runExternalCode` to pass. diff --git a/fixtures/compat-eslint/fixture.ts b/fixtures/compat-eslint/fixture.ts index 02b55322..a594fc64 100644 --- a/fixtures/compat-eslint/fixture.ts +++ b/fixtures/compat-eslint/fixture.ts @@ -1,9 +1,3 @@ -// @ts-nocheck let value = 9001; -// ^? let value: number - -// $ExpectError -value = 'over nine thousand'; - -// $ExpectType number -9001; +let unused = 'over nine thousand'; +console.log(value); diff --git a/fixtures/compat-eslint/package.json b/fixtures/compat-eslint/package.json index 43d61c2e..aa73c080 100644 --- a/fixtures/compat-eslint/package.json +++ b/fixtures/compat-eslint/package.json @@ -1,10 +1,9 @@ { "private": true, "name": "@tsslint-fixtures/compat-eslint", - "version": "3.1.4", + "version": "4.0.0-alpha.1", "devDependencies": { - "@tsslint/compat-eslint": "3.1.4", - "@tsslint/config": "3.1.4", - "eslint-plugin-expect-type": "^0.4.0" + "@tsslint/compat-eslint": "4.0.0-alpha.1", + "@tsslint/config": "4.0.0-alpha.1" } } diff --git a/fixtures/compat-eslint/tsslint.config.ts b/fixtures/compat-eslint/tsslint.config.ts index b8f275d8..d8aa686c 100644 --- a/fixtures/compat-eslint/tsslint.config.ts +++ b/fixtures/compat-eslint/tsslint.config.ts @@ -2,7 +2,7 @@ import { defineConfig, importESLintRules } from '@tsslint/config'; export default defineConfig({ rules: await importESLintRules({ - 'expect-type/expect': true, 'no-unused-vars': 'warn', + 'prefer-const': true, }), }); diff --git a/fixtures/compat-tsl/fixture.ts b/fixtures/compat-tsl/fixture.ts deleted file mode 100644 index 60b0bd67..00000000 --- a/fixtures/compat-tsl/fixture.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-nocheck -// nullable numbers are considered unsafe by default -let num: number | undefined = 0; -if (num) { - console.log('num is defined'); -} - -// nullable strings are considered unsafe by default -let str: string | null = null; -if (!str) { - console.log('str is empty'); -} - -// nullable booleans are considered unsafe by default -function foo(bool?: boolean) { - if (bool) { - bar(); - } -} - -// `any`, unconstrained generics and unions of more than one primitive type are disallowed -const foo = (arg: T) => (arg ? 1 : 0); - -// always-truthy and always-falsy types are disallowed -let obj = {}; -while (obj) { - obj = getObj(); -} diff --git a/fixtures/compat-tsl/package.json b/fixtures/compat-tsl/package.json deleted file mode 100644 index 8e41a398..00000000 --- a/fixtures/compat-tsl/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "private": true, - "name": "@tsslint-fixtures/compat-tsl", - "version": "3.1.4", - "devDependencies": { - "@tsslint/config": "3.1.4", - "tsl": "^1.0.28" - } -} diff --git a/fixtures/compat-tsl/tsconfig.json b/fixtures/compat-tsl/tsconfig.json deleted file mode 100644 index 78a35e16..00000000 --- a/fixtures/compat-tsl/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "include": ["fixture.ts"], - "compilerOptions": { - "noEmit": true, - "allowImportingTsExtensions": true - } -} diff --git a/fixtures/compat-tsl/tsslint.config.ts b/fixtures/compat-tsl/tsslint.config.ts deleted file mode 100644 index e93a1b17..00000000 --- a/fixtures/compat-tsl/tsslint.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig, fromTSLRules } from '@tsslint/config'; -import { core } from 'tsl'; - -export default defineConfig({ - rules: fromTSLRules(core.all()), -}); diff --git a/fixtures/compat-tslint/fixture.ts b/fixtures/compat-tslint/fixture.ts deleted file mode 100644 index 60b0bd67..00000000 --- a/fixtures/compat-tslint/fixture.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-nocheck -// nullable numbers are considered unsafe by default -let num: number | undefined = 0; -if (num) { - console.log('num is defined'); -} - -// nullable strings are considered unsafe by default -let str: string | null = null; -if (!str) { - console.log('str is empty'); -} - -// nullable booleans are considered unsafe by default -function foo(bool?: boolean) { - if (bool) { - bar(); - } -} - -// `any`, unconstrained generics and unions of more than one primitive type are disallowed -const foo = (arg: T) => (arg ? 1 : 0); - -// always-truthy and always-falsy types are disallowed -let obj = {}; -while (obj) { - obj = getObj(); -} diff --git a/fixtures/compat-tslint/package.json b/fixtures/compat-tslint/package.json deleted file mode 100644 index 5b630e48..00000000 --- a/fixtures/compat-tslint/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "private": true, - "name": "@tsslint-fixtures/compat-tslint", - "version": "3.1.4", - "devDependencies": { - "@tsslint/config": "3.1.4", - "tslint": "^6.1.3" - } -} diff --git a/fixtures/compat-tslint/tsconfig.json b/fixtures/compat-tslint/tsconfig.json deleted file mode 100644 index 78a35e16..00000000 --- a/fixtures/compat-tslint/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "include": ["fixture.ts"], - "compilerOptions": { - "noEmit": true, - "allowImportingTsExtensions": true - } -} diff --git a/fixtures/compat-tslint/tsslint.config.ts b/fixtures/compat-tslint/tsslint.config.ts deleted file mode 100644 index 338642f5..00000000 --- a/fixtures/compat-tslint/tsslint.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig, importTSLintRules } from '@tsslint/config'; - -export default defineConfig({ - rules: await importTSLintRules({ - 'strict-boolean-expressions': true, - }), -}); diff --git a/fixtures/define-plugin/tsslint.config.ts b/fixtures/define-plugin/tsslint.config.ts index 1696a2e3..1d59b46e 100644 --- a/fixtures/define-plugin/tsslint.config.ts +++ b/fixtures/define-plugin/tsslint.config.ts @@ -15,11 +15,11 @@ export default defineConfig({ function createIngorePlugin(pattern: RegExp) { return definePlugin(() => ({ - resolveDiagnostics(file, results) { + resolveDiagnostics({ file }, results) { const comments = [...file.text.matchAll(pattern)]; const lines = new Set(comments.map(comment => file.getLineAndCharacterOfPosition(comment.index).line)); return results.filter(error => - error.source !== 'tsslint' || !lines.has(file.getLineAndCharacterOfPosition(error.start).line - 1) + error.ruleId === undefined || !lines.has(file.getLineAndCharacterOfPosition(error.pos).line - 1) ); }, })); diff --git a/fixtures/meta-frameworks/fixture.astro b/fixtures/meta-frameworks/fixture.astro deleted file mode 100644 index d28c6842..00000000 --- a/fixtures/meta-frameworks/fixture.astro +++ /dev/null @@ -1,3 +0,0 @@ ---- -console.log('Hello, world!'); ---- diff --git a/fixtures/meta-frameworks/fixture.mdx b/fixtures/meta-frameworks/fixture.mdx deleted file mode 100644 index 8df956a7..00000000 --- a/fixtures/meta-frameworks/fixture.mdx +++ /dev/null @@ -1,6 +0,0 @@ -{/** Description of `Component` */} -export function Component() { - console.log('Hello, world!'); -} - -Hello diff --git a/fixtures/meta-frameworks/fixture.tsx b/fixtures/meta-frameworks/fixture.tsx deleted file mode 100644 index 59e3239f..00000000 --- a/fixtures/meta-frameworks/fixture.tsx +++ /dev/null @@ -1,3 +0,0 @@ -function MyComponent() { - return
{console.log('Hello, world!')}
; -} diff --git a/fixtures/meta-frameworks/fixture.vine.ts b/fixtures/meta-frameworks/fixture.vine.ts deleted file mode 100644 index f674a6b8..00000000 --- a/fixtures/meta-frameworks/fixture.vine.ts +++ /dev/null @@ -1,6 +0,0 @@ -function MyComponent() { - return vine`
{{ console.log('Hello, world!') }}
`; -} - -// This is also valid -const AnotherComponent = () => vine`
Hello World
`; diff --git a/fixtures/meta-frameworks/fixture.vue b/fixtures/meta-frameworks/fixture.vue deleted file mode 100644 index aa4f600b..00000000 --- a/fixtures/meta-frameworks/fixture.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/fixtures/meta-frameworks/package.json b/fixtures/meta-frameworks/package.json deleted file mode 100644 index 7eddf1ca..00000000 --- a/fixtures/meta-frameworks/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "private": true, - "name": "@tsslint-fixtures/meta-frameworks-support", - "version": "3.1.4", - "devDependencies": { - "@astrojs/ts-plugin": "latest", - "@mdx-js/language-service": "latest", - "@ts-macro/language-plugin": "latest", - "@vue-vine/language-service": "latest", - "@vue/language-core": "latest" - } -} diff --git a/fixtures/meta-frameworks/tsconfig.json b/fixtures/meta-frameworks/tsconfig.json deleted file mode 100644 index 28378984..00000000 --- a/fixtures/meta-frameworks/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "include": ["fixture.*"], - "compilerOptions": { - "noEmit": true, - "allowImportingTsExtensions": true - } -} diff --git a/fixtures/meta-frameworks/tsslint.config.ts b/fixtures/meta-frameworks/tsslint.config.ts deleted file mode 100644 index 5a057d37..00000000 --- a/fixtures/meta-frameworks/tsslint.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from '../../packages/config/index.js'; - -export default defineConfig({ - rules: { - 'no-console': (await import('../noConsoleRule.ts')), - }, -}); diff --git a/fixtures/typescript-plugin/fixture.ts b/fixtures/multiple-configs/fixture.mts similarity index 100% rename from fixtures/typescript-plugin/fixture.ts rename to fixtures/multiple-configs/fixture.mts diff --git a/fixtures/multiple-configs/fixture.vue b/fixtures/multiple-configs/fixture.vue deleted file mode 100644 index aa4f600b..00000000 --- a/fixtures/multiple-configs/fixture.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/fixtures/multiple-configs/tsslint.config.ts b/fixtures/multiple-configs/tsslint.config.ts index b1af7805..ab10f6ea 100644 --- a/fixtures/multiple-configs/tsslint.config.ts +++ b/fixtures/multiple-configs/tsslint.config.ts @@ -8,9 +8,9 @@ export default defineConfig([ }, }, { - include: ['**/*.vue'], + include: ['**/*.mts'], rules: { - 'no-console-vue': (await import('../noConsoleRule.ts')), + 'no-console-mts': (await import('../noConsoleRule.ts')), }, }, ]); diff --git a/fixtures/noConsoleRule.ts b/fixtures/noConsoleRule.ts index 05fcad43..6b45dab5 100644 --- a/fixtures/noConsoleRule.ts +++ b/fixtures/noConsoleRule.ts @@ -1,7 +1,7 @@ import { defineRule } from '../packages/config/index.js'; export default defineRule(({ typescript: ts, file, report }) => { - ts.forEachChild(file, function cb(node) { + file.forEachChild(function cb(node): void { if ( ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) @@ -14,17 +14,12 @@ export default defineRule(({ typescript: ts, file, report }) => { ).withFix( `Remove 'console.${node.name.text}'`, () => [{ - fileName: file.fileName, - textChanges: [{ - newText: '/* deleted */', - span: { - start: node.parent.getStart(file), - length: node.parent.getWidth(file), - }, - }], + newText: '/* deleted */', + pos: node.parent.getStart(file), + end: node.parent.getEnd(), }], ); } - ts.forEachChild(node, cb); + node.forEachChild(cb); }); }); diff --git a/fixtures/typescript-plugin/.vscode/extensions.json b/fixtures/typescript-plugin/.vscode/extensions.json deleted file mode 100644 index 7a6b26fb..00000000 --- a/fixtures/typescript-plugin/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "unwantedRecommendations": [ - "johnsoncodehk.vscode-tsslint" - ] -} diff --git a/fixtures/typescript-plugin/.vscode/settings.json b/fixtures/typescript-plugin/.vscode/settings.json deleted file mode 100644 index 4d360cbc..00000000 --- a/fixtures/typescript-plugin/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib" -} diff --git a/fixtures/typescript-plugin/package.json b/fixtures/typescript-plugin/package.json deleted file mode 100644 index 96ed5365..00000000 --- a/fixtures/typescript-plugin/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "private": true, - "name": "@tsslint-fixtures/typescript-plugin", - "version": "3.1.4", - "devDependencies": { - "@tsslint/typescript-plugin": "3.1.4", - "typescript": "latest" - } -} diff --git a/fixtures/typescript-plugin/tsconfig.json b/fixtures/typescript-plugin/tsconfig.json deleted file mode 100644 index 191b1c6e..00000000 --- a/fixtures/typescript-plugin/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "include": ["fixture.ts"], - "compilerOptions": { - "plugins": [{ "name": "@tsslint/typescript-plugin" }], - "noEmit": true, - "allowImportingTsExtensions": true - } -} diff --git a/fixtures/typescript-plugin/tsslint.config.ts b/fixtures/typescript-plugin/tsslint.config.ts deleted file mode 100644 index 5a057d37..00000000 --- a/fixtures/typescript-plugin/tsslint.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from '../../packages/config/index.js'; - -export default defineConfig({ - rules: { - 'no-console': (await import('../noConsoleRule.ts')), - }, -}); diff --git a/lerna.json b/lerna.json index eed9e2bf..67d29c43 100644 --- a/lerna.json +++ b/lerna.json @@ -4,7 +4,8 @@ "packages": [ ".", "packages/*", - "fixtures/*" + "fixtures/*", + "tools/*" ], - "version": "3.1.4" + "version": "4.0.0-alpha.1" } diff --git a/package.json b/package.json index f9096cb0..5442e9bc 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,23 @@ { "private": true, - "version": "3.1.4", + "version": "4.0.0-alpha.1", "packageManager": "pnpm@9.3.0", "scripts": { "test": "pnpm -F @tsslint/compat-eslint bench", "build": "tsc -b", "watch": "tsc -b -w", "release": "npm run build && lerna publish --exact --force-publish --yes --sync-workspace-lock", - "release:vscode": "npm run build && cd packages/vscode && npm run publish", "start": "packages/cli/bin/tsslint.js", "format": "dprint fmt", "lint": "packages/cli/bin/tsslint.js --project {tsconfig.json,packages/*/tsconfig.json}", "lint:fix": "npm run lint -- --fix && npm run format", - "lint:fixtures": "packages/cli/bin/tsslint.js --project fixtures/*/tsconfig.json --vue-project fixtures/meta-frameworks/tsconfig.json --mdx-project fixtures/meta-frameworks/tsconfig.json --astro-project fixtures/meta-frameworks/tsconfig.json --ts-macro-project fixtures/meta-frameworks/tsconfig.json" + "lint:fixtures": "packages/cli/bin/tsslint.js --project fixtures/*/tsconfig.json" }, "devDependencies": { "@lerna-lite/cli": "latest", "@lerna-lite/publish": "latest", "@types/node": "latest", - "@typescript-eslint/eslint-plugin": "latest", "dprint": "latest", - "eslint-plugin-react-x": "^4.2.3", - "typescript": "latest" + "typescript": "7.1.0-beta" } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 88088dc5..0b25fd85 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @tsslint/cli -Command-line runner for TSSLint. Lints TypeScript projects — and Vue / Vue Vine / MDX / Astro / TS Macro projects via Volar language plugins — in CI or from the terminal. +Command-line runner for TSSLint. Lints TypeScript projects in CI or from the terminal. ## Usage @@ -16,4 +16,4 @@ npx tsslint --project 'packages/*/tsconfig.json' --filter 'src/**/*.ts' Run `tsslint --help` for the full flag list. -See the [root README](../../README.md) for framework project flags (`--vue-project`, `--mdx-project`, …), caching behavior, and how diagnostics are emitted. +See the [root README](../../README.md) for caching behavior and how diagnostics are emitted. diff --git a/packages/cli/index.ts b/packages/cli/index.ts index c92a93f0..d2eb1648 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -9,31 +9,26 @@ if (process.argv.includes('--debug-estree')) { process.env.TSSLINT_DEBUG_ESTREE = '1'; } -import ts = require('typescript'); +import { version as tsVersion } from 'typescript'; +import { DiagnosticCategory } from 'typescript/sync'; import path = require('path'); import worker = require('./lib/worker.js'); import cache = require('./lib/cache.js'); import fs = require('fs'); import minimatch = require('minimatch'); -import languagePlugins = require('./lib/languagePlugins.js'); import colors = require('./lib/colors.js'); import render = require('./lib/render.js'); +import format = require('./lib/format.js'); const HELP = ` Usage: tsslint [options] Options: --project Lint TypeScript/JavaScript projects - --vue-project Lint Vue projects - --vue-vine-project Lint Vue Vine projects - --mdx-project Lint MDX projects - --astro-project Lint Astro projects - --ts-macro-project Lint TS Macro projects --filter Filter files to lint --fix Apply automatic fixes --force Ignore cache (re-lint every file) --failures-only Only print errors and messages (skip warnings and suggestions) - --list-rules After linting, print each rule's classification (syntactic / type-aware) --debug-estree After linting, print the actual ESTree node types converted by @tsslint/compat-eslint and their counts -h, --help Show this help message @@ -47,9 +42,6 @@ Examples: # Multiple projects with brace expansion tsslint --project {tsconfig.json,packages/*/tsconfig.json} - # Mixed framework projects - tsslint --project packages/*/tsconfig.json --vue-project apps/web/tsconfig.json - # With filter and fix tsslint --project tsconfig.json --filter "src/**/*.ts" --fix `; @@ -60,33 +52,19 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) { } const PROJECT_FLAGS = [ - { flag: '--project', language: undefined }, - { flag: '--vue-project', language: 'vue' }, - { flag: '--vue-vine-project', language: 'vue-vine' }, - { flag: '--mdx-project', language: 'mdx' }, - { flag: '--astro-project', language: 'astro' }, - { flag: '--ts-macro-project', language: 'ts-macro' }, -] as const; - -const LANGUAGE_LABELS = [ - { key: 'ts-macro', label: 'TS Macro', color: colors.tsMacroColor }, - { key: 'vue', label: 'Vue', color: colors.vueColor }, - { key: 'vue-vine', label: 'Vue Vine', color: colors.vueVineColor }, - { key: 'mdx', label: 'MDX', color: colors.mdxColor }, - { key: 'astro', label: 'Astro', color: colors.astroColor }, + { flag: '--project' }, ] as const; class Project { - worker: ReturnType | undefined; + started = false; /** - * file names for passing to ts language service host (getScriptFileNames) + * all file names of the tsconfig, including content-mapped files */ - rawFileNames: string[] = []; + rawFileNames: readonly string[] = []; /** * file names after filter, for linting process */ - fileNames: string[] = []; - options: ts.CompilerOptions = {}; + fileNames: readonly string[] = []; configFile: string | undefined; currentFileIndex = 0; cacheData: cache.CacheData = cache.emptyCache(); @@ -94,26 +72,12 @@ class Project { constructor( public tsconfig: string, - public languages: string[], - ) {} - - async init(renderer: render.Renderer, filesFilter: string[]) { - this.configFile = ts.findConfigFile(path.dirname(this.tsconfig), ts.sys.fileExists, 'tsslint.config.ts'); - - const labels: string[] = []; - - if (this.languages.length === 0) { - labels.push(colors.tsColor('TS')); - } - else { - for (const { key, label, color } of LANGUAGE_LABELS) { - if (this.languages.includes(key)) { - labels.push(color(label)); - } - } - } + ) { + this.configFile = findConfigFile(path.dirname(tsconfig)); + } - const label = labels.join(colors.gray(' | ')); + init(renderer: render.Renderer, filesFilter: string[]) { + const label = colors.tsColor('TS'); const relPath = path.relative(process.cwd(), this.tsconfig); if (!this.configFile) { @@ -123,10 +87,7 @@ class Project { return this; } - const commonLine = await parseCommonLine(this.tsconfig, this.languages); - - this.rawFileNames = commonLine.fileNames; - this.options = commonLine.options; + this.rawFileNames = worker.getProjectFileNames(this.tsconfig); if (!this.rawFileNames.length) { renderer.info(`${label} ${colors.gray(relPath)} ${colors.gray('(0)')}`); @@ -156,38 +117,32 @@ class Project { colors.gray(`(${this.fileNames.length}${filteredLengthDiff ? `, skipped ${filteredLengthDiff}` : ''})`) }`; - // Load layer-1 cache unless --force was passed. The cache file path - // key includes tsslint version, TS version, tsconfig, languages, - // and configFile mtime+size — anything that changes the rule set or - // the toolchain mints a fresh file. See packages/cli/lib/cache.ts. + // Load the cache unless --force was passed; `worker.setup` drops + // whatever is no longer valid. See packages/cli/lib/invalidation.ts. if (!process.argv.includes('--force')) { - this.cacheData = cache.loadCache( - this.tsconfig, - this.configFile, - this.languages, - ts.version, - ts.sys.createHash, - ); + this.cacheData = cache.loadCache(this.tsconfig, this.configFile, tsVersion); } return this; } } -const formatHost: ts.FormatDiagnosticsHost = { - getCurrentDirectory: ts.sys.getCurrentDirectory, - getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? x => x : x => x.toLowerCase(), - getNewLine: () => ts.sys.newLine, -}; - (async () => { const renderer = render.createRenderer(); - const tsconfigAndLanguages = new Map(); + const tsconfigs = new Set(); + + function exit(code: number): never { + renderer.dispose(); + try { + worker.close(); + } + catch {} + process.exit(code); + } function fail(msg: string): never { renderer.error(colors.red(msg)); - renderer.dispose(); - process.exit(1); + exit(1); } let projects: Project[] = []; @@ -209,7 +164,7 @@ const formatHost: ts.FormatDiagnosticsHost = { process.exit(1); } - for (const { flag, language } of PROJECT_FLAGS) { + for (const { flag } of PROJECT_FLAGS) { if (!process.argv.includes(flag)) { continue; } @@ -221,18 +176,12 @@ const formatHost: ts.FormatDiagnosticsHost = { } foundArg = true; const searchGlob = process.argv[i]; - const tsconfigs = fs.globSync(searchGlob); - if (!tsconfigs.length) { + const matches = fs.globSync(searchGlob); + if (!matches.length) { fail(`No projects found for ${flag} ${searchGlob}.`); } - for (let tsconfig of tsconfigs) { - tsconfig = resolvePath(tsconfig); - if (!tsconfigAndLanguages.has(tsconfig)) { - tsconfigAndLanguages.set(tsconfig, []); - } - if (language) { - tsconfigAndLanguages.get(tsconfig)!.push(language); - } + for (const tsconfig of matches) { + tsconfigs.add(resolvePath(tsconfig)); } } if (!foundArg) { @@ -245,7 +194,7 @@ const formatHost: ts.FormatDiagnosticsHost = { if (fs.statSync(filterPath, { throwIfNoEntry: false })?.isDirectory()) { filterPath = path.join(filterPath, '**/*'); } - return ts.server.toNormalizedPath(filterPath); + return filterPath.replace(/\\/g, '/'); } const filters: string[] = []; @@ -265,8 +214,13 @@ const formatHost: ts.FormatDiagnosticsHost = { } } - for (const [tsconfig, languages] of tsconfigAndLanguages) { - projects.push(await new Project(tsconfig, languages).init(renderer, filters)); + projects = [...tsconfigs].map(tsconfig => new Project(tsconfig)); + const configured = projects.filter(project => project.configFile).map(project => project.tsconfig); + if (configured.length) { + worker.openProjects(configured); + } + for (const project of projects) { + project.init(renderer, filters); } projects = projects.filter(project => project.configFile && project.fileNames.length); @@ -277,11 +231,10 @@ const formatHost: ts.FormatDiagnosticsHost = { if (allFilesNum === 0) { renderer.info(colors.yellow('No input files.')); - renderer.dispose(); - process.exit(1); + exit(1); } - await startWorker(worker.create()); + await startWorker(); const summaryLines: string[] = []; @@ -334,40 +287,6 @@ const formatHost: ts.FormatDiagnosticsHost = { renderer.summary(summaryLines); - if (process.argv.includes('--list-rules')) { - // Derive from the per-project cacheData: any rule with an entry - // in `ruleModes` is type-aware; the rest came up as cached - // entries on per-file maps without being classified, so they - // ran as syntactic. Output is grouped + alphabetised so it's - // stable across runs and easy to diff. - const typeAware = new Set(); - const syntactic = new Set(); - for (const project of projects) { - for (const ruleId of Object.keys(project.cacheData.ruleModes)) { - typeAware.add(ruleId); - } - for (const file of Object.values(project.cacheData.files)) { - for (const ruleId of Object.keys(file.rules)) { - if (!project.cacheData.ruleModes[ruleId]) syntactic.add(ruleId); - } - } - } - // A rule classified type-aware in any project is type-aware - // everywhere — drop it from the syntactic side to avoid - // double-listing the same id across multi-project runs. - for (const id of typeAware) syntactic.delete(id); - - // Always emit both headers — even with count 0 — so the format - // stays consistent across runs (otherwise a project with only - // type-aware rules looks visually different from one with both). - const lines: string[] = []; - lines.push(colors.cyan('type-aware') + colors.gray(` (${typeAware.size})`)); - for (const id of [...typeAware].sort()) lines.push(' ' + id); - lines.push(colors.cyan('syntactic') + colors.gray(` (${syntactic.size})`)); - for (const id of [...syntactic].sort()) lines.push(' ' + id); - for (const l of lines) renderer.info(l); - } - if (process.argv.includes('--debug-estree')) { // compat-eslint's lazy-estree publishes a per-`type` counter on // globalThis under Symbol.for('@tsslint/compat-eslint:node-type-counts') @@ -429,114 +348,72 @@ const formatHost: ts.FormatDiagnosticsHost = { } } - renderer.dispose(); + exit((errors || messages || configErrors) ? 1 : 0); - process.exit((errors || messages || configErrors) ? 1 : 0); - - async function startWorker(linterWorker: ReturnType) { + async function startWorker() { const unfinishedProjects = projects.filter(project => project.currentFileIndex < project.fileNames.length); if (!unfinishedProjects.length) { return; } - const project = unfinishedProjects.find(project => !project.worker); + const project = unfinishedProjects.find(project => !project.started); if (!project) { return; } - project.worker = linterWorker; + project.started = true; if (project.pendingHeader) { renderer.info(project.pendingHeader); project.pendingHeader = undefined; } - const setupResult = await linterWorker.setup( - project.tsconfig, - project.languages, + const setupResult = await worker.setup( project.configFile!, - project.rawFileNames, - project.options, - Object.keys(project.cacheData.ruleModes), - project.cacheData.incrementalState, + project.tsconfig, + project.cacheData, ); if (setupResult !== true) { renderer.diagnostic(formatConfigError(project.configFile!, setupResult)); configErrors++; projects = projects.filter(p => p !== project); - await startWorker(linterWorker); + await startWorker(); return; } while (project.currentFileIndex < project.fileNames.length) { const fileName = project.fileNames[project.currentFileIndex++]; - const fileStat = fs.statSync(fileName, { throwIfNoEntry: false }); - if (!fileStat) { + if (!fs.statSync(fileName, { throwIfNoEntry: false })) { continue; } - let fileCache = project.cacheData.files[fileName]; - if (!fileCache) { - fileCache = { mtime: fileStat.mtimeMs, rules: {} }; - project.cacheData.files[fileName] = fileCache; - } - else if (fileCache.mtime === fileStat.mtimeMs && Object.keys(fileCache.rules).length) { - // File text untouched since the prev session AND we have at - // least one rule's diagnostics cached for it — treat as a - // warm hit for the `--force` summary hint. Layer 2's BP - // might still re-run type-aware rules if their deps moved, - // but the user-visible signal here is just "cache had - // something for this file." + if (project.cacheData.files[fileName]) { cached++; } - const diagnostics = await linterWorker.lint( - fileName, - process.argv.includes('--fix'), - fileCache, - fileStat.mtimeMs, - ); + const diagnostics = worker.lint(fileName, process.argv.includes('--fix')); if (diagnostics.length) { - hasFix ||= await linterWorker.hasCodeFixes(fileName); + // A cache hit didn't run any rule this session, so its fixes + // are only known from the cache entry. + hasFix ||= worker.hasCodeFixes(fileName) || !!project.cacheData.files[fileName]?.hasFix; for (const diagnostic of diagnostics) { - // Cache-hit diagnostics come back without their rule - // having registered a fix this session — `hasCodeFixes` - // only sees fresh runs. Fall back to the cached - // per-rule `hasFix` flag for the diagnostic's rule. - hasFix ||= !!fileCache.rules[String(diagnostic.code)]?.hasFix; - - let output: string; - - if (diagnostic.category === ts.DiagnosticCategory.Suggestion) { - output = ts.formatDiagnosticsWithColorAndContext([{ - ...diagnostic, - category: ts.DiagnosticCategory.Message, - }], formatHost); - output = output.replace(/\[94mmessage/, '[90msuggestion'); - output = output.replace(/\[94m/g, '[90m'); - } - else { - output = ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost); + const output = format.formatDiagnostic(diagnostic, worker.getSourceFile(fileName)!, worker.getApi()); + if (output === undefined) { + continue; } - output = output.trimEnd(); - - if (typeof diagnostic.code === 'string') { - output = output.replace(`TS${diagnostic.code}`, diagnostic.code); - } - - if (diagnostic.category === ts.DiagnosticCategory.Error) { + if (diagnostic.category === DiagnosticCategory.Error) { errors++; renderer.diagnostic(output); } - else if (diagnostic.category === ts.DiagnosticCategory.Warning) { + else if (diagnostic.category === DiagnosticCategory.Warning) { warnings++; if (!failuresOnly) { renderer.diagnostic(output); } } - else if (diagnostic.category === ts.DiagnosticCategory.Message) { + else if (diagnostic.category === DiagnosticCategory.Message) { messages++; renderer.diagnostic(output); } @@ -548,37 +425,15 @@ const formatHost: ts.FormatDiagnosticsHost = { } } } - else if (await linterWorker.hasRules(fileName)) { + else if (worker.hasRules(fileName)) { passed++; } processed++; } - // Snapshot the linter's runtime classification back into the cache - // file's `ruleModes`. Next session reads this and seeds the linter - // via `initialTypeAwareRules` so rules are classified correctly - // from the first invocation, before the runtime probe re-runs. - const typeAware = await linterWorker.getTypeAwareRules(); - project.cacheData.ruleModes = {}; - for (const ruleId of typeAware) { - project.cacheData.ruleModes[ruleId] = 'type-aware'; - } - // Layer 2: harvest content hashes + transitive deps from a fresh - // BuilderProgram pass over the LS program. Persists alongside the - // per-rule cache so the next `--incremental` session can compute - // affected files. Falls through to `undefined` on layer-1-only - // runs, matching the schema contract. - project.cacheData.incrementalState = await linterWorker.buildIncrementalState(); - cache.saveCache( - project.tsconfig, - project.configFile!, - project.languages, - ts.version, - project.cacheData, - ts.sys.createHash, - ); + cache.saveCache(project.tsconfig, project.configFile!, tsVersion, project.cacheData); - await startWorker(linterWorker); + await startWorker(); } function resolvePath(p: string) { @@ -618,17 +473,16 @@ function formatConfigError(configFile: string, errorText: string): string { return out.join('\n'); } -async function parseCommonLine(tsconfig: string, languages: string[]) { - const jsonConfigFile = ts.readJsonConfigFile(tsconfig, ts.sys.readFile); - const plugins = await languagePlugins.load(tsconfig, languages); - const extraFileExtensions = plugins.flatMap(plugin => plugin.typescript?.extraFileExtensions ?? []).flat(); - return ts.parseJsonSourceFileConfigFileContent( - jsonConfigFile, - ts.sys, - path.dirname(tsconfig), - {}, - tsconfig, - undefined, - extraFileExtensions, - ); +function findConfigFile(dir: string): string | undefined { + while (true) { + const configFile = path.join(dir, 'tsslint.config.ts'); + if (fs.statSync(configFile, { throwIfNoEntry: false })?.isFile()) { + return configFile; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } } diff --git a/packages/cli/lib/cache-flow.ts b/packages/cli/lib/cache-flow.ts index 4dac4c7e..681abf28 100644 --- a/packages/cli/lib/cache-flow.ts +++ b/packages/cli/lib/cache-flow.ts @@ -1,162 +1,36 @@ -// Cache-aware wrapper around `linter.lint`. Decides which rules to skip -// based on the file's cached entries + the linter's type-aware -// classification, calls into core with `skipRules`, and merges the -// freshly-computed diagnostics with the rehydrated cached ones. -// -// Core knows nothing about the cache shape or lifecycle. It just runs -// rules and returns diagnostics. Everything in this module is the -// CLI's responsibility — serialization, mtime invalidation, sticky -// type-aware cleanup. +// Cache-aware wrapper around `linter.lint`. A file's cached result is the +// diagnostics it produced after plugins ran; `lib/invalidation.ts` deletes the +// entries that are no longer valid before linting starts, so an entry that is +// still here can be replayed as is. -import { type Linter, NO_CACHE } from '@tsslint/core'; -import type * as ts from 'typescript'; -import type { FileCache, SerializedDiagnostic } from './cache.js'; +import type { Linter } from '@tsslint/core'; +import type { Diagnostic, LintTarget } from '@tsslint/types'; +import type { FileCache } from './cache.js'; export function lintWithCache( linter: Linter, - fileName: string, - fileCache: FileCache, - fileMtime: number, - program: ts.Program, + target: LintTarget, + files: Record, options?: { - // Layer 2 master switch. Driven by the CLI's `--incremental` flag. - // When false / undefined (mode A), type-aware rules are never - // cached: their entries are deleted after each run. When true - // (mode B), type-aware rules' fresh results get persisted so the - // next session can reuse them. - incremental?: boolean; - // File-level signal: the caller's affected-file tracker says this - // file's type-relevant inputs (own text, transitive deps incl. - // ambient `.d.ts` and lib) haven't moved since the prior session. - // Only meaningful in mode B. When true, type-aware rule cache - // entries are also cache-hit-eligible (skipped via the run's - // skipRules set). - typeAwareUnaffected?: boolean; + // Fixes are closures that don't survive the cache, so a file with + // fixes has to be linted again to apply them. + fix?: boolean; + // Text hashes of the files the rules read; without it none are + // recorded. + hashFiles?: (fileNames: readonly string[]) => Record; }, -): ts.DiagnosticWithLocation[] { - // File mtime is the layer-1 invalidation key. Anything that touched - // the file's text drops every cached rule entry — those rules will - // re-run because they vanish from the skip set. - if (fileCache.mtime !== fileMtime) { - fileCache.mtime = fileMtime; - fileCache.rules = {}; +): Diagnostic[] { + const fileName = target.file.fileName; + const cached = files[fileName]; + if (cached && !(options?.fix && cached.hasFix)) { + return cached.diagnostics.map(diagnostic => ({ ...diagnostic })); } - const writeTypeAware = options?.incremental === true; - const trustTypeAwareCache = writeTypeAware && options?.typeAwareUnaffected === true; - const typeAware = linter.getTypeAwareRules(); - - // Cache hit only when this file has a cache entry for the rule AND - // either: - // - the rule is not classified type-aware, or - // - the rule is type-aware but the caller signalled the file is - // unaffected this session (layer 2) - const skipRules = new Set(); - for (const ruleId of Object.keys(fileCache.rules)) { - if (typeAware.has(ruleId)) { - if (trustTypeAwareCache) { - skipRules.add(ruleId); - } - // else: re-run; the post-rule write path will overwrite the - // stale entry or delete it depending on classification. - } - else { - skipRules.add(ruleId); - } - } - - // Restore cached diagnostics BEFORE calling lint. They're seeded as - // `prevDiagnostics` so plugin `resolveDiagnostics` (e.g. ignore plugin - // matching disable comments to fired diagnostics) sees the same view - // it would on a cold pass — without this, skipped rules' diagnostics - // are missing during plugin filtering and every successful suppression - // turns into a warm-only unused-comment false positive. - const restored: ts.DiagnosticWithLocation[] = []; - for (const ruleId of skipRules) { - const entry = fileCache.rules[ruleId]; - if (!entry) continue; - for (const sd of entry.diagnostics) { - restored.push(deserializeDiagnostic(sd, fileName, program)); - } - } - - const fresh = linter.lint(fileName, { skipRules, prevDiagnostics: restored }); - - // Cache writes key off the PRE-suppression rule output (`getRawLintResult`), - // not the post-plugin `fresh`. Otherwise a diagnostic the ignore plugin - // successfully suppressed would never make it to disk, and the next - // warm pass would have nothing to feed back in via `prevDiagnostics` - // — re-creating the very FP this is fixing. - const raw = linter.getRawLintResult(fileName); - const byRule = new Map(); - for (const diag of raw) { - const ruleId = String(diag.code); - let bucket = byRule.get(ruleId); - if (!bucket) byRule.set(ruleId, bucket = []); - bucket.push(diag); - } - - // For every rule that actually ran (i.e. not skipped), update the - // cache. Type-aware rules in mode A (no `incremental`): delete any - // entry, never write. Otherwise (syntactic, or type-aware in mode - // B): write the freshly-computed entry — even if the file was - // considered affected this run, so the next session can cache-hit. - const allRuleIds = Object.keys(linter.getRules(fileName)); - for (const ruleId of allRuleIds) { - if (skipRules.has(ruleId)) continue; - if (typeAware.has(ruleId) && !writeTypeAware) { - delete fileCache.rules[ruleId]; - continue; - } - const diags = byRule.get(ruleId) ?? []; - let hasFix = false; - for (const diag of diags) { - if (linter.hasFixForDiagnostic(fileName, diag)) { - hasFix = true; - break; - } - } - // Drop diagnostics the rule marked via `Reporter.withoutCache()` — - // the rule is asserting they depend on inputs we don't track in - // the cache key, so a warm replay would be unsound. They're still - // included in the live `fresh` array returned from this function; - // they just don't survive to disk. - fileCache.rules[ruleId] = { - hasFix, - diagnostics: diags - .filter(d => !(d as any)[NO_CACHE]) - .map(serializeDiagnostic), - }; - } - - // `fresh` already contains the survived `restored` items (seeded via - // `prevDiagnostics`) plus the rules that ran this session, post-plugin. - return fresh; -} - -function serializeDiagnostic(d: ts.DiagnosticWithLocation): SerializedDiagnostic { - const { file, relatedInformation, ...rest } = d; - void file; - return { - ...rest, - relatedInformation: relatedInformation?.map(info => ({ - ...info, - file: info.file ? { fileName: info.file.fileName } : undefined, - })), - }; -} - -function deserializeDiagnostic( - sd: SerializedDiagnostic, - fileName: string, - program: ts.Program, -): ts.DiagnosticWithLocation { - return { - ...sd, - file: program.getSourceFile(fileName)!, - relatedInformation: sd.relatedInformation?.map(info => ({ - ...info, - file: info.file ? program.getSourceFile(info.file.fileName) : undefined, - })), + const diagnostics = linter.lint(target); + files[fileName] = { + hasFix: linter.hasCodeFixes(fileName), + diagnostics: diagnostics.map(diagnostic => ({ ...diagnostic })), + touched: options?.hashFiles?.(linter.getTouchedFiles(fileName)) ?? {}, }; + return diagnostics; } diff --git a/packages/cli/lib/cache.ts b/packages/cli/lib/cache.ts index 804a8df4..be0b8926 100644 --- a/packages/cli/lib/cache.ts +++ b/packages/cli/lib/cache.ts @@ -5,12 +5,13 @@ // // Path key includes: // - tsslint version (segment) → bumping the package invalidates everything -// - typescript version (segment) → .tsbuildinfo format / TS internals can -// change across TS majors, so segregate per TS version -// - hash of (configFilePath, tsconfig, languages, configFile mtime+size) -// → editing tsslint.config.ts mints a fresh cache file +// - typescript version (segment) → checker output can change across TS +// versions, so segregate per TS version +// - hash of (configFilePath, tsconfig) // -// Cache file shape — see packages/cli/CACHE.md "Cache file format" section. +// Everything else that can invalidate results (compiler options, config +// and rule modules, content mappers, files in the program) is checked +// through `globalKey` and the dependency graph; see `lib/invalidation.ts`. // // Soundness invariants the loader enforces: // - any parse / shape / version mismatch returns an empty cache (treated @@ -24,59 +25,73 @@ import fs = require('fs'); import os = require('os'); import crypto = require('crypto'); -import type * as ts from 'typescript'; -import type { IncrementalState } from './incremental-state.js'; +import type { Diagnostic } from '@tsslint/types'; const pkg = require('../package.json'); // Bump when the on-disk shape changes incompatibly. Mismatching version // returns an empty cache; we don't migrate (cold start is the safe default). -const CACHE_FORMAT_VERSION = 'v2'; +const CACHE_FORMAT_VERSION = 'v11'; export interface CacheData { version: string; - // Sticky per-rule classification. Only "type-aware" entries are - // stored — anything missing is treated as syntactic (or unclassified, - // which currently means the same thing: cache write happens). - ruleModes: Record; + // Hash of every input whose change re-lints all files. + globalKey?: string; + // Every program file outside `node_modules`. + graph: Record; + // Lint results that are still valid, by linted file. files: Record; - // Layer 2 cross-session state. Present iff the previous session ran - // with `--incremental`. Lets the next session compute which files' - // type-relevant inputs (incl. ambient `.d.ts`) have changed since. - // See `lib/incremental-state.ts`. Optional so layer-1-only sessions - // stay schema-clean. - incrementalState?: IncrementalState; } -export interface FileCache { +export interface GraphNode { mtime: number; - rules: Record; + size: number; + // Hash of the file's declaration emit, or of its text when there is + // none. Only kept for files that other files depend on, and for global + // files. + signature?: string; + // Program files outside `node_modules` this file imports or references. + deps: string[]; + // Per dep, the export names this file imports from it; `\0\0` for the + // whole module. + uses?: string[][]; + // Declares globals or augments modules, so a signature change can affect + // any file. + global: boolean; + // Hash of the `declare global` / `declare module '...'` blocks of a + // global module; a script or a UMD module has none. + scope?: string; + // What the blocks of a global module use, as references (see `names`). + scopeRefs?: string[]; + // For a module other files depend on, per export name, per top-level + // declaration or import (`\0` + its name) and for `export *` (`\0*`): + // the hash of its declaration emit, then what it references. A reference + // is a key of the same file, `\0` for an export of + // `deps[i]` (`\0\0\0` for the whole module), or `''` for a module + // specifier that did not resolve. An empty hash marks one whose types + // changed through a reference since it was emitted. + names?: Record; + // Keys of `names` whose references are not read yet; their entries only + // hold the hash until a change can reach the file. + pending?: string[]; } -export interface RuleCache { +// Diagnostics after plugins ran, as the CLI reports them. +export interface FileCache { hasFix: boolean; - diagnostics: SerializedDiagnostic[]; + diagnostics: Diagnostic[]; + // Hash of the text of each other program file the rules read beyond its + // declarations (see `getTouchedFiles` in @tsslint/core). + touched: Record; } -// `ts.DiagnosticWithLocation` minus the live `file` reference — that gets -// re-attached on load by looking up the SourceFile from the current Program. -// `relatedInformation` similarly stores `{ fileName }` placeholders. -export type SerializedDiagnostic = Omit & { - relatedInformation?: SerializedRelatedInfo[]; -}; - -export type SerializedRelatedInfo = Omit & { - file?: { fileName: string }; -}; - export function loadCache( tsconfig: string, configFilePath: string, - languages: string[], tsVersion: string, createHash: (s: string) => string = defaultHash, ): CacheData { - const filePath = getCacheFilePath(tsconfig, configFilePath, languages, tsVersion, createHash); + const filePath = getCacheFilePath(tsconfig, configFilePath, tsVersion, createHash); if (!fs.statSync(filePath, { throwIfNoEntry: false })?.isFile()) { return emptyCache(); } @@ -106,12 +121,11 @@ export function loadCache( export function saveCache( tsconfig: string, configFilePath: string, - languages: string[], tsVersion: string, cache: CacheData, createHash: (s: string) => string = defaultHash, ): void { - const filePath = getCacheFilePath(tsconfig, configFilePath, languages, tsVersion, createHash); + const filePath = getCacheFilePath(tsconfig, configFilePath, tsVersion, createHash); fs.mkdirSync(path.dirname(filePath), { recursive: true }); const tmpPath = filePath + '.tmp'; fs.writeFileSync(tmpPath, JSON.stringify(cache)); @@ -123,7 +137,7 @@ export function saveCache( } export function emptyCache(): CacheData { - return { version: CACHE_FORMAT_VERSION, ruleModes: {}, files: {} }; + return { version: CACHE_FORMAT_VERSION, graph: {}, files: {} }; } // Deep shape check. A corrupt `files` entry (wrong `mtime` type, missing @@ -134,19 +148,20 @@ function isCacheData(x: unknown): x is CacheData { if (typeof x !== 'object' || x === null) return false; const o = x as Record; if (typeof o.version !== 'string') return false; - if (typeof o.ruleModes !== 'object' || o.ruleModes === null) return false; + if (o.globalKey !== undefined && typeof o.globalKey !== 'string') return false; + if (typeof o.graph !== 'object' || o.graph === null) return false; + for (const v of Object.values(o.graph as Record)) { + if (typeof v !== 'object' || v === null) return false; + const n = v as Record; + if (typeof n.mtime !== 'number' || typeof n.size !== 'number') return false; + if (!Array.isArray(n.deps) || typeof n.global !== 'boolean') return false; + } if (typeof o.files !== 'object' || o.files === null) return false; for (const v of Object.values(o.files as Record)) { if (typeof v !== 'object' || v === null) return false; const f = v as Record; - if (typeof f.mtime !== 'number') return false; - if (typeof f.rules !== 'object' || f.rules === null) return false; - } - if (o.incrementalState !== undefined) { - if (typeof o.incrementalState !== 'object' || o.incrementalState === null) return false; - const inc = o.incrementalState as Record; - if (typeof inc.version !== 'string') return false; - if (typeof inc.tsBuildInfoText !== 'string') return false; + if (typeof f.hasFix !== 'boolean' || !Array.isArray(f.diagnostics)) return false; + if (typeof f.touched !== 'object' || f.touched === null) return false; } return true; } @@ -154,18 +169,10 @@ function isCacheData(x: unknown): x is CacheData { function getCacheFilePath( tsconfig: string, configFilePath: string, - languages: string[], tsVersion: string, createHash: (s: string) => string, ): string { - const configStat = fs.statSync(configFilePath, { throwIfNoEntry: false }); - const cacheKey = [ - configFilePath, - tsconfig, - languages.sort().join(','), - configStat?.mtimeMs ?? 0, - configStat?.size ?? 0, - ].join('\0'); + const cacheKey = [configFilePath, tsconfig].join('\0'); return path.join( getTsslintCachePath(), tsVersion, diff --git a/packages/cli/lib/config-modules.ts b/packages/cli/lib/config-modules.ts new file mode 100644 index 00000000..3b50f51f --- /dev/null +++ b/packages/cli/lib/config-modules.ts @@ -0,0 +1,65 @@ +// Tracks which modules a tsslint config loads, so editing a rule file or +// upgrading a dependency it imports invalidates the cache the same way +// editing the config does. + +import crypto = require('crypto'); +import fs = require('fs'); +import Module = require('module'); +import url = require('url'); + +const imports = new Map>(); +let tracking = false; + +// Must run before the config is imported; modules loaded earlier have no +// recorded edges. +export function trackImports() { + if (tracking) { + return; + } + tracking = true; + // `Module.registerHooks` was added in Node.js 22.15, the minimum version. + const registerHooks = (Module as unknown as { registerHooks: (hooks: object) => void }).registerHooks; + registerHooks({ + resolve( + specifier: string, + context: { parentURL?: string }, + nextResolve: (specifier: string, context: object) => { url: string }, + ) { + const result = nextResolve(specifier, context); + if (context.parentURL) { + let children = imports.get(context.parentURL); + if (!children) { + imports.set(context.parentURL, children = new Set()); + } + children.add(result.url); + } + return result; + }, + }); +} + +// Name, mtime and size of every file module reachable from the config. +// Modules a rule imports lazily while linting are not covered. +export function getConfigModulesKey(configFile: string): string { + const entry = url.pathToFileURL(configFile).href; + const seen = new Set([entry]); + const queue = [entry]; + while (queue.length) { + for (const child of imports.get(queue.pop()!) ?? []) { + if (!seen.has(child)) { + seen.add(child); + queue.push(child); + } + } + } + const hash = crypto.createHash('sha256'); + for (const moduleUrl of [...seen].sort()) { + if (!moduleUrl.startsWith('file:')) { + continue; + } + const fileName = url.fileURLToPath(moduleUrl); + const stat = fs.statSync(fileName, { throwIfNoEntry: false }); + hash.update(`\0${fileName}\0${stat?.mtimeMs ?? -1}\0${stat?.size ?? -1}`); + } + return hash.digest('hex'); +} diff --git a/packages/cli/lib/content-mapper.ts b/packages/cli/lib/content-mapper.ts new file mode 100644 index 00000000..8259a511 --- /dev/null +++ b/packages/cli/lib/content-mapper.ts @@ -0,0 +1,42 @@ +// Files produced by a tsconfig `contentMappers` entry (e.g. `.vue`) carry +// the transformed TypeScript in `file.text` and the user's file in +// `file.originalText`; `file.spanMap` relates the two. Rules see and +// report in transformed coordinates; everything the user sees or that +// touches disk goes through here. + +import { type ReadonlyTextRange, type SourceFile, SpanMap } from 'typescript/ast'; +import type { TextEdit } from 'typescript/sync'; + +export function getOriginalText(file: SourceFile): string { + return file.spanMap ? file.originalText : file.text; +} + +// Undefined when the range only covers generated code with no counterpart +// in the original file — there is nothing for the user to look at. +export function toOriginalRange(file: SourceFile, range: ReadonlyTextRange): ReadonlyTextRange | undefined { + if (!file.spanMap) { + return range; + } + const mapped = file.spanMap.virtualToOriginalSpan(range); + if (SpanMap.isNone(mapped.fidelity)) { + return undefined; + } + return mapped.range; +} + +// Undefined unless every edit maps exactly onto original text; an +// approximate edit could corrupt the user's file. +export function toOriginalTextEdits(file: SourceFile, edits: readonly TextEdit[]): TextEdit[] | undefined { + if (!file.spanMap) { + return [...edits]; + } + const result: TextEdit[] = []; + for (const edit of edits) { + const { range, fidelity } = file.spanMap.virtualToOriginalSpan(edit); + if (!SpanMap.isExact(fidelity)) { + return undefined; + } + result.push({ pos: range.pos, end: range.end, newText: edit.newText }); + } + return result; +} diff --git a/packages/cli/lib/format.ts b/packages/cli/lib/format.ts new file mode 100644 index 00000000..9745e719 --- /dev/null +++ b/packages/cli/lib/format.ts @@ -0,0 +1,115 @@ +// Renders diagnostics with TS7's diagnostic formatter, in the coordinates +// of the file the user actually wrote. + +import type { Diagnostic } from '@tsslint/types'; +import path = require('path'); +import type { SourceFile } from 'typescript/ast'; +import { + type Diagnostic as TSDiagnostic, + type FormatDiagnosticsHost, + formatDiagnosticsWithColorAndContext, +} from 'typescript/sync'; +import { cyan } from './colors.js'; +import contentMapper = require('./content-mapper.js'); + +const lineStartsCache = new WeakMap(); + +// Undefined when the diagnostic lies entirely in generated code of a +// content-mapped file. +export function formatDiagnostic( + diagnostic: Diagnostic, + file: SourceFile, + host: FormatDiagnosticsHost, +): string | undefined { + // The formatter prints `${source}${code}`, so `source` is only kept to + // carry a rule id. + const converted = diagnostic.ruleId === undefined + ? { ...diagnostic, source: undefined, fileName: file.fileName } + : convertRuleReport(diagnostic, diagnostic.ruleId, file); + if (!converted) { + return undefined; + } + const output = formatDiagnosticsWithColorAndContext([converted], host).trimEnd(); + if (converted.fileName === undefined) { + // The formatter only names the file together with a position. + return cyan(path.relative(host.getCurrentDirectory(), file.fileName)) + ' - ' + output; + } + return output; +} + +// Rule reports are in transformed coordinates and carry no line +// information; compiler diagnostics already come back with both in the +// original file. +function convertRuleReport(diagnostic: Diagnostic, ruleId: string, file: SourceFile): TSDiagnostic | undefined { + // There are no string codes. + const code = { source: ruleId, code: '' as unknown as number }; + if (diagnostic.fileName === undefined) { + return { ...diagnostic, ...code }; + } + const range = contentMapper.toOriginalRange(file, diagnostic); + if (!range) { + return undefined; + } + const text = contentMapper.getOriginalText(file); + const lineStarts = getLineStarts(file, text); + const startPosition = toLineAndCharacter(lineStarts, range.pos); + const endPosition = toLineAndCharacter(lineStarts, range.end); + return { + ...diagnostic, + ...code, + fileName: file.fileName, + pos: range.pos, + end: range.end, + startPosition, + endPosition, + sourceLines: getSourceLines(text, lineStarts, startPosition.line, endPosition.line), + }; +} + +function getLineStarts(file: SourceFile, text: string): number[] { + let lineStarts = lineStartsCache.get(file); + if (!lineStarts) { + lineStarts = [0]; + for (let i = 0; i < text.length; i++) { + const ch = text.charCodeAt(i); + if (ch === 13 /* \r */) { + if (text.charCodeAt(i + 1) === 10 /* \n */) { + i++; + } + lineStarts.push(i + 1); + } + else if (ch === 10 /* \n */ || ch === 0x2028 || ch === 0x2029) { + lineStarts.push(i + 1); + } + } + lineStartsCache.set(file, lineStarts); + } + return lineStarts; +} + +function toLineAndCharacter(lineStarts: number[], position: number) { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (lineStarts[mid] <= position) { + low = mid; + } + else { + high = mid - 1; + } + } + return { line: low, character: position - lineStarts[low] }; +} + +// Same context window as `tsc`: every line for short spans, otherwise the +// first two and last two (the formatter prints an ellipsis for the gap). +function getSourceLines(text: string, lineStarts: number[], startLine: number, endLine: number) { + const lines = endLine - startLine >= 4 + ? [startLine, startLine + 1, endLine - 1, endLine] + : Array.from({ length: endLine - startLine + 1 }, (_, i) => startLine + i); + return lines.map(line => { + const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1] : text.length; + return { line, text: text.slice(lineStarts[line], lineEnd).replace(/(?:\r\n|[\r\n\u2028\u2029])$/, '') }; + }); +} diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts deleted file mode 100644 index 01556cfa..00000000 --- a/packages/cli/lib/incremental-state.ts +++ /dev/null @@ -1,183 +0,0 @@ -// Layer 2 cross-session state. Wraps TypeScript's internal -// `getBuildInfo` / `createBuilderProgramUsingIncrementalBuildInfo` -// so two `tsslint --incremental` runs can share BuilderProgram state -// across processes via TS's own `tsBuildInfoFile` format. -// -// Why internal API: the public `BuilderProgram` interface only takes -// an `oldProgram` of type `SemanticDiagnosticsBuilderProgram` — there's -// no public path from disk back to BP. Manually serialising via -// `getAllDependencies` + content hash works (we shipped that in -// 68140be) but produces 17MB+ JSON on Dify-scale projects, drops -// node_modules tracking to fit, and uses content hashes where TS -// would use shape signatures. The internal route gives: -// - shape signatures (smarter than content hash — body-only edits -// don't propagate) -// - bit-packed compact format (380 bytes for a 3-file program in -// our spike, vs kilobytes manual) -// - node_modules tracking included -// - all the edge cases TS already debugged: ambient declarations, -// module augmentations, lib changes, project references -// -// Risk surface: -// - `getBuildInfo`, `createBuilderProgramUsingIncrementalBuildInfo` -// are not in `typescript.d.ts`. They've been stable across TS -// 5.x → 6.x but no contract. Cache key already includes -// `ts.version`, so version skew gives a clean miss instead of -// corrupted state. -// - On parse / shape mismatch, treat as cold start (the standard -// "miss is always safe" invariant). - -import type * as ts from 'typescript'; - -// Synthetic path used as the `tsBuildInfoFile` value passed to TS. -// TS uses this only to resolve relative paths inside the buildinfo -// payload — we never actually write here on disk; the buildinfo text -// is captured via the BuilderProgramHost's `writeFile` callback and -// stored in `IncrementalState.tsBuildInfoText`. -export const SYNTHETIC_BUILD_INFO_PATH = '/__tsslint__.tsbuildinfo'; - -// Hard cap on the captured buildinfo text length. The TS internal -// format is compact (~3.6MB on Dify's 5867 files), so 64MB leaves -// ~10–15× headroom on that scale — comfortably below V8's max string -// length and below the size where JSON.stringify of the surrounding -// cache object starts to feel sticky. If the cap fires we warn + skip -// persistence (cold start next session is preferable to a multi-second -// JSON serialise hit on every run). -const MAX_BUILD_INFO_BYTES = 64 * 1024 * 1024; - -export interface IncrementalState { - version: string; - // Raw text TS wrote via `BuilderProgram.emitBuildInfo`. Opaque to - // us — fed straight back to `ts.getBuildInfo` on the next session. - tsBuildInfoText: string; -} - -// Format version. Bump if we ever change what we store alongside -// `tsBuildInfoText`. The internal TS format itself is keyed on -// `ts.version` via the cache file's path component. -export const INCREMENTAL_STATE_VERSION = 'v3'; - -interface IncrementalAccess { - getBuildInfo(file: string, text: string): unknown | undefined; - createBuilderProgramUsingIncrementalBuildInfo( - buildInfo: unknown, - buildInfoPath: string, - host: { useCaseSensitiveFileNames(): boolean; getCurrentDirectory(): string }, - ): ts.BuilderProgram; -} - -// Cast helper. Keeps the unsafe access concentrated and easy to find -// when TS upgrades. -export function asIncremental(ts: typeof import('typescript')): IncrementalAccess { - return ts as unknown as IncrementalAccess; -} - -// Warn the user that layer 2 cache is unavailable this session. -// Goes to stderr (yellow) so it doesn't interleave with the -// renderer's stdout output but stays visible by default. CI logs -// stderr by default; users who pipe stderr to /dev/null know what -// they signed up for. -// -// Each call site phrases its own reason — these are rare enough -// that we don't need rate-limiting. A single CLI invocation can -// emit at most one of each (load fails once at setup, save fails -// once at end-of-project). -function warn(msg: string): void { - process.stderr.write('\x1b[93mwarn\x1b[0m ' + msg + '\n'); -} - -// Reconstruct an old BuilderProgram from a previously captured -// `tsBuildInfoText`. Used as the `oldProgram` argument when creating -// the current session's BP. Returns undefined on any deserialization -// failure (cold-start fallback). -export function reconstructOldBuilder( - ts: typeof import('typescript'), - prev: IncrementalState | undefined, - host: { useCaseSensitiveFileNames(): boolean; getCurrentDirectory(): string }, -): ts.BuilderProgram | undefined { - // Schema-version mismatch and "no prev cache" are normal cold-start - // paths (e.g. cache invalidated by config change, or first run); - // don't warn about either. - if (!prev || prev.version !== INCREMENTAL_STATE_VERSION) return undefined; - const api = asIncremental(ts); - if ( - typeof api.getBuildInfo !== 'function' - || typeof api.createBuilderProgramUsingIncrementalBuildInfo !== 'function' - ) { - warn( - `TypeScript ${ts.version} is missing internal APIs ` - + `(getBuildInfo / createBuilderProgramUsingIncrementalBuildInfo). ` - + `Type-aware cache disabled — every type-aware rule will re-run from cold start.`, - ); - return undefined; - } - try { - const buildInfo = api.getBuildInfo(SYNTHETIC_BUILD_INFO_PATH, prev.tsBuildInfoText); - if (!buildInfo) return undefined; - return api.createBuilderProgramUsingIncrementalBuildInfo( - buildInfo, - SYNTHETIC_BUILD_INFO_PATH, - host, - ); - } - catch (e) { - warn( - `Could not load previous incremental state on TypeScript ${ts.version}: ` - + `${(e as Error).message}. Type-aware cache will rebuild from cold start.`, - ); - return undefined; - } -} - -// Capture a `tsBuildInfoText` from a live BuilderProgram. Triggers a -// `BuilderProgram.emitBuildInfo` with a writeFile callback that -// intercepts what TS would otherwise write to disk. -// -// `emitBuildInfo` is on the runtime `BuilderProgram` shape but not in -// the public d.ts (the public surface only exposes the `emit()` family -// that wraps it). Cast through `unknown` to silence the type error; -// runtime is stable across TS 5.x → 6.x. -// -// On any failure (method missing on a future TS, or it threw), warn + -// return undefined. Caller persists no `incrementalState` for this -// session, which means next session starts cold for layer 2 — the -// layer-1 mtime cache still works. Wrong miss > wrong hit. -export function captureIncrementalState( - tsVersion: string, - builder: ts.BuilderProgram, -): IncrementalState | undefined { - const builderAny = builder as unknown as { - emitBuildInfo?(writeFile: (path: string, content: string) => void): void; - }; - if (typeof builderAny.emitBuildInfo !== 'function') { - warn( - `TypeScript ${tsVersion} BuilderProgram is missing emitBuildInfo. ` - + `Type-aware cache cannot be persisted — next run will start cold.`, - ); - return undefined; - } - let captured: string | undefined; - try { - builderAny.emitBuildInfo((_path, content) => { - captured = content; - }); - } - catch (e) { - warn( - `Could not persist incremental state on TypeScript ${tsVersion}: ` - + `${(e as Error).message}. Type-aware cache cannot be persisted — next run will start cold.`, - ); - return undefined; - } - if (!captured) return undefined; - if (captured.length > MAX_BUILD_INFO_BYTES) { - const mb = (captured.length / 1024 / 1024).toFixed(1); - const capMb = MAX_BUILD_INFO_BYTES / 1024 / 1024; - warn( - `Incremental state too large (${mb}MB > ${capMb}MB cap). ` - + `Type-aware cache cannot be persisted — next run will start cold.`, - ); - return undefined; - } - return { version: INCREMENTAL_STATE_VERSION, tsBuildInfoText: captured }; -} diff --git a/packages/cli/lib/invalidation.ts b/packages/cli/lib/invalidation.ts new file mode 100644 index 00000000..726b5a1a --- /dev/null +++ b/packages/cli/lib/invalidation.ts @@ -0,0 +1,1652 @@ +// Decides which cached lint results are still valid, the way `tsc --incremental` +// decides which files to re-check. A file's result is reused when the file is +// unchanged and none of the names it imports changed their types, so an edit +// inside a function body re-lints only that file. +// +// Each module other files depend on keeps, per export and per top-level +// declaration, a hash of its declaration emit and what it references: the +// declarations and imports the checker resolves its identifiers to, and the +// exports of other modules these name. A name's types changed when its own +// hash changed, or when one it references changed, also across files and +// through cycles; that is, when a hash it reaches changed, as if each name +// hashed the hashes of what it references. References are read from the +// source, not the declaration emit, so they stay valid while a dependency +// changes the emit of an unchanged file, and only once a change can reach +// the module. +// +// What can't be told apart counts as more, never less: +// - a namespace import, `import =`, `import('./a')` and a module specifier +// no declaration accounts for reference the whole module +// - an identifier the checker resolves to nothing references every +// top-level name of its text +// - a declaration only the emit has (e.g. `_default`) references what +// `export default` / `export =` does, or every dep +// - a JS, JSON, script or UMD file, or one without a single declaration +// output, is one name referencing the whole of every dep +// - an unchanged file whose name went stale counts it as changed again +// on its next edit +// +// A rule can also read another file beyond its declarations (e.g. the body of +// a function it calls). The file's result then also depends on that file's +// full text, and on what that file's own types depend on. +// +// Some changes can affect any file; they re-lint everything: +// - compiler options, tsconfig files, content mappers, config and rule modules +// - files added to or removed from the program, or changes under `node_modules` +// - a changed signature of a script or a UMD module (`export as namespace`) +// - a changed `declare global` / `declare module '...'` block, or a changed +// name its blocks use: an import, a declaration of the module itself, or +// any export of the augmented module +// - a file starting or stopping to declare globals + +import crypto = require('crypto'); +import fs = require('fs'); +import path = require('path'); +import * as ts from 'typescript/ast'; +import type { API, Checker, Project } from 'typescript/sync'; +import type { CacheData, GraphNode } from './cache.js'; + +const CHUNK_SIZE = 500; +// Keys in `GraphNode.names` beside export names, which are identifiers or +// string literals; neither can hold `\0`. +const LOCAL = '\0'; +const STAR = '\0*'; +// The whole module: every export, also through `export *`. +const ALL = '\0\0'; +// Stands for a module specifier that did not resolve; always changed. +const UNKNOWN = ''; + +interface Declaration { + fileName: string; + text: string; +} + +interface FileInfo { + deps: string[]; + // Per dep, the export names the file imports from it. + uses: string[][]; + global: boolean; + // For a module with `declare global` / `declare module '...'` blocks; a + // script or a UMD module is global as a whole. + scope?: Scope; + // How each module specifier and reference in the file resolved; `null` + // when it points outside the graph. + references: Map; +} + +interface Scope { + // Hash of the blocks' text. + hash: string; + // What the blocks use, as references. + refs: string[]; +} + +export function invalidate( + api: API, + project: Project, + cache: CacheData, + configModulesKey: string, + fileHashes: Map, +) { + const program = project.program; + const ownFiles = new Map(); + const globalHash = crypto.createHash('sha256'); + globalHash.update(JSON.stringify(program.getCompilerOptions())); + globalHash.update(`\0${configModulesKey}`); + for (const configFile of program.getConfigFileNames()) { + globalHash.update(`\0${configFile}\0${readFile(configFile)}`); + updateContentMappersHash(api, globalHash, configFile); + } + for (const fileName of [...program.getSourceFileNames()].sort()) { + const stat = fs.statSync(fileName, { throwIfNoEntry: false }); + if (fileName.includes('/node_modules/')) { + globalHash.update(`\0${fileName}\0${stat?.mtimeMs ?? -1}\0${stat?.size ?? -1}`); + } + else { + // Bundled lib files have no on-disk path; the TS version in the + // cache path covers them. + globalHash.update(`\0${fileName}`); + if (stat) { + ownFiles.set(fileName, stat); + } + } + } + const globalKey = globalHash.digest('hex'); + + const changed: string[] = []; + for (const [fileName, stat] of ownFiles) { + const node = cache.graph[fileName]; + if (!node || node.mtime !== stat.mtimeMs || node.size !== stat.size) { + changed.push(fileName); + } + } + + if (cache.globalKey !== globalKey) { + return rebuild(); + } + if (!changed.length) { + return; + } + const infos = analyze(api, project, changed, ownFiles); + if ( + changed.some(fileName => { + const node = cache.graph[fileName]; + return !node || node.global !== infos.get(fileName)!.global; + }) + ) { + return rebuild(infos); + } + + const previous = new Map(); + let scopeChanged = false; + for (const fileName of changed) { + previous.set(fileName, cache.graph[fileName]); + scopeChanged ||= cache.graph[fileName].scope !== infos.get(fileName)!.scope?.hash; + setNode(cache, fileName, ownFiles.get(fileName)!, infos.get(fileName)!); + } + const importers = updateNames(api, project, cache, ownFiles, infos); + + const changedSet = new Set(changed); + const graph = createGraph( + cache, + previous, + importers, + fileNames => readPendingRefs(api, project, cache, ownFiles, infos, fileNames), + ); + const used = new Map(); + for (const fileName of Object.keys(cache.files)) { + const node = cache.graph[fileName]; + if (node && !changedSet.has(fileName)) { + used.set(fileName, node.deps.flatMap((dep, i) => (node.uses?.[i] ?? [ALL]).map(name => graph.get(dep, name)))); + } + } + const globals: string[] = []; + for (const [fileName, node] of Object.entries(cache.graph)) { + if (node.global) { + globals.push(...node.scopeRefs?.map(ref => graph.resolve(fileName, ref)) ?? [graph.get(fileName, ALL)]); + } + } + const changedNames = graph.solve(); + markChanged(cache, previous, changedNames); + // The graph stays valid; only the results can't be reused. + if (scopeChanged || globals.some(id => changedNames.has(id))) { + cache.files = {}; + return; + } + const dirty = new Set(changed); + for (const [fileName, ids] of used) { + if (ids.some(id => changedNames.has(id))) { + dirty.add(fileName); + } + } + // A file read for its syntax only matters through its text and its + // types, not through what its own rules read. + const retyped = new Set(dirty); + for (const [fileName, entry] of Object.entries(cache.files)) { + if (dirty.has(fileName)) { + continue; + } + for (const [touched, textHash] of Object.entries(entry.touched)) { + if (changedSet.has(touched) ? hashFile(touched, fileHashes) !== textHash : retyped.has(touched)) { + dirty.add(fileName); + break; + } + } + } + for (const fileName of dirty) { + delete cache.files[fileName]; + } + + function rebuild(known?: Map) { + cache.globalKey = globalKey; + cache.files = {}; + cache.graph = {}; + const all = [...ownFiles.keys()]; + const allInfos = analyze(api, project, all, ownFiles, known); + for (const fileName of all) { + setNode(cache, fileName, ownFiles.get(fileName)!, allInfos.get(fileName)!); + } + updateNames(api, project, cache, ownFiles, allInfos); + } +} + +function setNode(cache: CacheData, fileName: string, stat: fs.Stats, info: FileInfo) { + const node: GraphNode = { + mtime: stat.mtimeMs, + size: stat.size, + deps: info.deps, + uses: info.uses, + global: info.global, + }; + if (info.scope) { + node.scope = info.scope.hash; + node.scopeRefs = info.scope.refs; + } + cache.graph[fileName] = node; +} + +function getImporters(cache: CacheData) { + const importers = new Map>(); + for (const [fileName, node] of Object.entries(cache.graph)) { + for (const dep of node.deps) { + let set = importers.get(dep); + if (!set) { + importers.set(dep, set = new Set()); + } + set.add(fileName); + } + } + return importers; +} + +// Signatures and names are only kept for files other files depend on, and +// for files that declare globals. +function updateNames( + api: API, + project: Project, + cache: CacheData, + ownFiles: Map, + infos: Map, +) { + const importers = getImporters(cache); + const missing: string[] = []; + for (const [fileName, node] of Object.entries(cache.graph)) { + if (!importers.has(fileName) && !node.global) { + delete node.signature; + delete node.names; + delete node.pending; + } + else if (node.signature === undefined) { + missing.push(fileName); + } + } + const unanalyzed = missing.filter(fileName => !infos.has(fileName)); + for (const [fileName, info] of analyze(api, project, unanalyzed, ownFiles)) { + infos.set(fileName, info); + } + const entries: [string, Declaration[] | undefined][] = []; + for (const [fileName, { declaration, signature }] of getDeclarations(api, project, missing)) { + cache.graph[fileName].signature = signature; + entries.push([fileName, declaration]); + } + for (const [fileName, read] of getNames(api, project, entries, infos, cache)) { + const node = cache.graph[fileName]; + delete node.pending; + if (read) { + node.names = read.names; + if (read.pending.length) { + node.pending = read.pending; + } + } + else { + delete node.names; + } + } + return importers; +} + +function getName(names: Record | undefined, key: string) { + return names && Object.hasOwn(names, key) ? names[key] : undefined; +} + +function isLocal(key: string) { + return key.startsWith(LOCAL) && key !== STAR && key !== ALL; +} + +// The names of all files as one graph, from each name to what it +// references. A file without names (a script, a JS or JSON file, or one +// without a single declaration output) is one node, which references the +// whole of every file it depends on. +// +// A name references names of its own file and of the files it depends on, +// and a name found through `export *` those of the re-exported files. So a +// file's names can only change when a name of the file, of a dep, or of a +// file a dep re-exports changed, or when one references a module specifier +// that did not resolve; the graph only reads the names of such files, and +// reads the references of a file whose names were stored without them +// (`GraphNode.pending`) then. Those can't name an unresolved specifier: the +// source only names those of `file.imports`, which all resolved or point +// outside the graph. +function createGraph( + cache: CacheData, + previous: Map, + importers: Map>, + readRefs: (fileNames: string[]) => void, +) { + const dependents = new Map(); + // Per file, each name added so far, by id. + const added = new Map>(); + const expanded = new Set(); + const pending: [id: string, fileName: string, key: string][] = []; + const result = new Set(); + const queue: string[] = []; + const get = (fileName: string, key: string) => { + const names = cache.graph[fileName]?.names; + if (!names || (key.startsWith(LOCAL) && key !== ALL && !getName(names, key))) { + key = ALL; + } + const id = fileName + '\0' + key; + let ids = added.get(fileName); + if (!ids) { + added.set(fileName, ids = new Map()); + } + if (!ids.has(id)) { + ids.set(id, key); + if (expanded.has(fileName)) { + pending.push([id, fileName, key]); + } + } + return id; + }; + const resolve = (fileName: string, ref: string, key?: string) => { + if (ref === UNKNOWN) { + return UNKNOWN; + } + const separator = ref.indexOf('\0'); + if (separator <= 0) { + return get(fileName, key ?? ref); + } + const dep = cache.graph[fileName].deps[+ref.slice(0, separator)]; + return dep === undefined ? UNKNOWN : get(dep, key ?? ref.slice(separator + 1)); + }; + const mark = (id: string) => { + if (!result.has(id)) { + result.add(id); + queue.push(id); + } + }; + const link = (from: string, to: string) => { + let list = dependents.get(to); + if (!list) { + dependents.set(to, list = []); + } + list.push(from); + if (result.has(to)) { + mark(from); + } + }; + const expand = (id: string, fileName: string, key: string) => { + const node = cache.graph[fileName]; + const names = node?.names; + if (!names) { + for (const dep of node?.deps ?? []) { + link(id, get(dep, ALL)); + } + return; + } + const stars = getName(names, STAR)?.slice(1) ?? []; + if (key === ALL) { + const keys = new Set([...Object.keys(names), ...Object.keys(previous.get(fileName)?.names ?? {})]); + for (const name of keys) { + if (!isLocal(name)) { + link(id, get(fileName, name)); + } + } + for (const ref of stars) { + link(id, resolve(fileName, ref)); + } + return; + } + const entry = getName(names, key); + if (entry) { + if (key !== STAR) { + for (const ref of entry.slice(1)) { + link(id, resolve(fileName, ref)); + } + } + return; + } + // An export not found by its name comes from `export *`, or from + // the properties of `export =`. + if (stars.length) { + link(id, get(fileName, STAR)); + for (const ref of stars) { + link(id, resolve(fileName, ref, key)); + } + } + if (getName(names, 'export=')) { + link(id, get(fileName, 'export=')); + } + }; + const expandFile = (fileName: string) => { + expanded.add(fileName); + for (const [id, key] of [...added.get(fileName) ?? []]) { + pending.push([id, fileName, key]); + } + const node = cache.graph[fileName]; + for (const key of Object.keys(node?.names ?? {})) { + get(fileName, key); + } + if (node && !node.names && node.signature !== undefined) { + get(fileName, ALL); + } + }; + return { + get, + resolve, + // Every name whose hash or a hash it reaches changed. + solve() { + // Per file, the files that re-export it through `export *`. + const reexporters = new Map(); + const requested = new Set(); + let unread: string[] = []; + const request = (fileName: string) => { + if (!requested.has(fileName)) { + requested.add(fileName); + if (cache.graph[fileName]?.pending) { + unread.push(fileName); + } + else { + expandFile(fileName); + } + } + }; + // A reference to an unresolved specifier is always changed. + for (const [fileName, node] of Object.entries(cache.graph)) { + if (!node.names) { + continue; + } + for (const [key, entry] of Object.entries(node.names)) { + for (let i = 1; i < entry.length; i++) { + const ref = entry[i]; + const separator = ref.indexOf('\0'); + const dep = separator > 0 ? node.deps[+ref.slice(0, separator)] : undefined; + if (ref === UNKNOWN || (separator > 0 && dep === undefined)) { + request(fileName); + } + else if (key === STAR && dep !== undefined) { + let list = reexporters.get(dep); + if (!list) { + reexporters.set(dep, list = []); + } + list.push(fileName); + } + } + } + } + mark(UNKNOWN); + for (const [fileName, old] of previous) { + const { names, signature } = cache.graph[fileName]; + if (!names || !old.names) { + if (names || old.names || signature === undefined || signature !== old.signature) { + mark(fileName + '\0' + ALL); + } + } + for (const key of new Set([...Object.keys(names ?? {}), ...Object.keys(old.names ?? {})])) { + if (getName(names, key)?.[0] !== getName(old.names, key)?.[0]) { + mark(fileName + '\0' + key); + } + } + } + const reached = new Set(); + const reach = (fileName: string) => { + const stack = [fileName]; + request(fileName); + while (stack.length) { + const file = stack.pop()!; + if (reached.has(file)) { + continue; + } + reached.add(file); + for (const importer of importers.get(file) ?? []) { + request(importer); + } + stack.push(...reexporters.get(file) ?? []); + } + }; + while (true) { + while (pending.length || queue.length) { + if (pending.length) { + expand(...pending.pop()!); + continue; + } + const id = queue.pop()!; + const separator = id.indexOf('\0'); + if (separator > 0) { + reach(id.slice(0, separator)); + } + for (const dependent of dependents.get(id) ?? []) { + mark(dependent); + } + } + if (!unread.length) { + return result; + } + const fileNames = unread; + unread = []; + readRefs(fileNames); + for (const fileName of fileNames) { + expandFile(fileName); + } + } + }, + }; +} + +// The declaration emit of an unchanged file goes stale when a name it +// references changes. Clear the hashes of such names, so the next edit of +// the file counts them as changed whatever the new emit is. +function markChanged(cache: CacheData, previous: Map, changedNames: Set) { + for (const [fileName, node] of Object.entries(cache.graph)) { + if (previous.has(fileName)) { + continue; + } + if (node.names) { + for (const [key, entry] of Object.entries(node.names)) { + if (isLocal(key) && changedNames.has(fileName + '\0' + key)) { + entry[0] = ''; + } + } + } + else if (node.signature !== undefined && changedNames.has(fileName + '\0' + ALL)) { + node.signature = ''; + } + } +} + +function analyze( + api: API, + project: Project, + fileNames: string[], + ownFiles: Map, + known?: Map, + // Whether to read what `declare global` / `declare module '...'` blocks + // use, as `FileInfo.scope`. + readScopes = true, +) { + const { program } = project; + const infos = new Map(); + const pending: string[] = []; + for (const fileName of fileNames) { + const info = known?.get(fileName); + if (info) { + infos.set(fileName, info); + } + else { + pending.push(fileName); + } + } + for (let i = 0; i < pending.length; i += CHUNK_SIZE) { + const files = api.batch(...pending.slice(i, i + CHUNK_SIZE).map(fileName => program.getSourceFile.gen(fileName))) + .filter((file): file is ts.SourceFile => !!file); + // An augmented module needs no import. + const specifiers = files.map(file => + [ + ...file.imports, + ...file.moduleAugmentations.filter(name => ts.isStringLiteral(name)), + ] as unknown as ts.StringLiteral[] + ); + const requests = files.flatMap((file, i) => [ + ...specifiers[i].map(specifier => + program.getResolvedModuleFromModuleSpecifier.gen(specifier as any, file.fileName) + ), + ...file.typeReferenceDirectives.map(ref => + program.getResolvedTypeReferenceDirectiveFromTypeReferenceDirective.gen(ref, file.fileName) + ), + ]); + const resolved = api.batch(...requests) as ({ resolvedFileName: string } | undefined)[]; + let index = 0; + const targets = files.map((file, i) => { + const fileTargets: [specifier: string, target: string | undefined][] = []; + for (const specifier of specifiers[i]) { + fileTargets.push([specifier.text, resolved[index++]?.resolvedFileName]); + } + for (const ref of file.typeReferenceDirectives) { + fileTargets.push([ref.fileName, resolved[index++]?.resolvedFileName]); + } + const dir = path.dirname(file.fileName); + for (const ref of file.referencedFiles) { + const base = path.resolve(dir, ref.fileName); + fileTargets.push([ + ref.fileName, + [base, base + '.ts', base + '.tsx', base + '.d.ts'].find(f => fs.existsSync(f)), + ]); + } + return fileTargets; + }); + // A resolution can spell a program file differently than the program + // does (e.g. through a symlinked directory); the program's own lookup + // gives its name. + const unknown = [ + ...new Set( + targets.flat() + .map(([, target]) => target) + .filter((target): target is string => + target !== undefined && !ownFiles.has(target) && !target.includes('/node_modules/') + ), + ), + ]; + const programNames = new Map(); + api.batch(...unknown.map(target => program.getSourceFile.gen(target))).forEach((file, i) => { + programNames.set(unknown[i], file?.fileName); + }); + const scoped: [ts.SourceFile, FileInfo][] = []; + files.forEach((file, i) => { + const deps = new Set(); + const references = new Map(); + for (const [specifier, target] of targets[i]) { + const name = target !== undefined && programNames.has(target) ? programNames.get(target) : target; + const dep = name !== undefined && ownFiles.has(name) ? name : null; + if (dep !== null && dep !== file.fileName) { + deps.add(dep); + } + // The same specifier can resolve differently per resolution + // mode; keep any in-graph target. + if (!references.get(specifier)) { + references.set(specifier, dep); + } + } + const info: FileInfo = { + deps: [...deps], + uses: getUses(file, specifiers[i], references, [...deps]), + global: false, + references, + }; + if (!file.fileName.endsWith('.json')) { + if (!file.externalModuleIndicator || file.statements.some(ts.isNamespaceExportDeclaration)) { + info.global = true; + } + else if (file.moduleAugmentations.length || file.ambientModuleNames.length) { + info.global = true; + if (readScopes) { + scoped.push([file, info]); + } + } + } + infos.set(file.fileName, info); + }); + const scopes = getScopes(api, project.checker, scoped.map(([file]) => file)); + scoped.forEach(([file, info], i) => { + const { hash, specifiers, imports, locals } = scopes[i]; + const refs = new Set(); + const add = (specifier: string, key: string) => { + if (info.references.get(specifier) !== file.fileName) { + const ref = encodeRef(file.fileName, info.references, info.deps, specifier, key); + if (ref !== undefined) { + refs.add(ref); + } + } + }; + for (const specifier of specifiers) { + add(specifier, ALL); + } + for (const [specifier, names] of imports) { + for (const name of names) { + add(specifier, name); + } + } + for (const name of locals) { + refs.add(LOCAL + name); + } + info.scope = { hash, refs: [...refs] }; + }); + } + return infos; +} + +// A reference to `key` of the module a specifier names: an export name or +// `ALL`. `undefined` for a module outside the graph. +function encodeRef( + fileName: string, + references: Map, + deps: string[], + specifier: string, + key: string, +) { + const target = references.get(specifier); + if (target === undefined) { + return UNKNOWN; + } + if (target === null) { + return undefined; + } + return target === fileName ? key : `${deps.indexOf(target)}\0${key}`; +} + +// What the file imports from each dep. A module specifier outside an +// `import` / `export` statement (e.g. `import('./a')`, an import type or an +// augmented module) and a `/// ` use the whole module. +function getUses( + file: ts.SourceFile, + specifiers: ts.StringLiteral[], + references: Map, + deps: string[], +) { + const used = new Map>(); + const use = (specifier: string, name?: string) => { + const target = references.get(specifier); + if (!target) { + return; + } + let names = used.get(target); + if (!names) { + used.set(target, names = new Set()); + } + if (name !== undefined) { + names.add(name); + } + }; + const attributed = new Set(); + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + attributed.add(statement.moduleSpecifier); + const specifier = statement.moduleSpecifier.text; + const clause = statement.importClause; + use(specifier); + if (clause?.name) { + use(specifier, 'default'); + } + const named = clause?.namedBindings; + if (named && ts.isNamespaceImport(named)) { + use(specifier, ALL); + } + else if (named) { + for (const element of named.elements) { + use(specifier, (element.propertyName ?? element.name).text); + } + } + } + else if ( + ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ) { + attributed.add(statement.moduleSpecifier); + const specifier = statement.moduleSpecifier.text; + const clause = statement.exportClause; + if (!clause || ts.isNamespaceExport(clause)) { + use(specifier, ALL); + } + else { + for (const element of clause.elements) { + use(specifier, (element.propertyName ?? element.name).text); + } + } + } + } + for (const specifier of specifiers) { + if (!attributed.has(specifier)) { + use(specifier.text, ALL); + } + } + for (const ref of [...file.typeReferenceDirectives, ...file.referencedFiles]) { + use(ref.fileName, ALL); + } + return deps.map(dep => [...used.get(dep) ?? [ALL]]); +} + +interface Binding { + // For an import; the export name is `*` for a whole module. + specifier?: string; + name: string; +} + +interface ScopeUses { + hash: string; + // Module specifiers the blocks name, e.g. the augmented module or an + // `import('./a')` type. + specifiers: Set; + // Export names the blocks use through the imports of each module + // specifier; `ALL` for a namespace or `import =` binding. + imports: Map>; + // Names of the module's own declarations the blocks use. + locals: Set; +} + +// What the `declare global` / `declare module '...'` blocks of each file +// use, by resolving every identifier in them to its symbol. Identifiers that +// resolve to neither an import nor a declaration of the module are declared +// by the blocks themselves, globally elsewhere, or in the augmented module, +// which `ScopeUses.specifiers` covers. +function getScopes(api: API, checker: Checker, files: ts.SourceFile[]): ScopeUses[] { + const collected = files.map(collectScope); + const requests = collected.filter(({ used }) => used.length); + const resolved = new Map( + api.batch(...requests.map(({ bindings, used }) => checker.getSymbolAtLocation.gen([...bindings.keys(), ...used]))) + .map((symbols, i) => [requests[i], symbols]), + ); + return collected.map(scope => { + const { hash, specifiers, bindings } = scope; + const result: ScopeUses = { hash, specifiers, imports: new Map(), locals: new Set() }; + const symbols = resolved.get(scope); + if (!symbols) { + return result; + } + const byId = new Map(); + // An identifier the checker resolves to nothing may still be a + // binding, so it uses every binding of its name. + const byText = new Map(); + [...bindings].forEach(([node, binding], i) => { + const symbol = symbols[i]; + if (symbol) { + byId.set(symbol.id, binding); + } + let list = byText.get(node.text); + if (!list) { + byText.set(node.text, list = []); + } + list.push(binding); + }); + symbols.slice(bindings.size).forEach((symbol, i) => { + const found = symbol ? [byId.get(symbol.id)] : byText.get(scope.used[i].text) ?? []; + for (const binding of found) { + if (!binding) { + continue; + } + if (binding.specifier === undefined) { + result.locals.add(binding.name); + continue; + } + let names = result.imports.get(binding.specifier); + if (!names) { + result.imports.set(binding.specifier, names = new Set()); + } + names.add(binding.name); + } + }); + return result; + }); +} + +function collectScope(file: ts.SourceFile) { + let text = ''; + const specifiers = new Set(); + const used: ts.Identifier[] = []; + // The module's imports and declarations outside the blocks, by name node. + const bindings = new Map(); + const visitBlock = (node: ts.Node): void => { + if (ts.isIdentifier(node)) { + used.push(node); + } + else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + specifiers.add(node.name.text); + } + else if ( + ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal) + ) { + specifiers.add(node.argument.literal.text); + } + node.forEachChild(visitBlock); + }; + for (const statement of file.statements) { + if (isBlock(statement)) { + text += file.text.slice(statement.pos, statement.end) + '\0'; + visitBlock(statement); + } + else if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause; + if (!clause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const specifier = statement.moduleSpecifier.text; + if (clause.name) { + bindings.set(clause.name, { specifier, name: 'default' }); + } + const named = clause.namedBindings; + if (named && ts.isNamespaceImport(named)) { + bindings.set(named.name, { specifier, name: ALL }); + } + else if (named) { + for (const element of named.elements) { + bindings.set(element.name, { specifier, name: (element.propertyName ?? element.name).text }); + } + } + } + else if (ts.isImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + bindings.set( + statement.name, + ts.isExternalModuleReference(reference) && ts.isStringLiteral(reference.expression) + ? { specifier: reference.expression.text, name: ALL } + : { name: statement.name.text }, + ); + } + else { + visitDeclarations(statement, name => bindings.set(name, { name: name.text })); + } + } + return { hash: hash(text), specifiers, bindings, used }; +} + +function isBlock(statement: ts.Statement): statement is ts.ModuleDeclaration { + return ts.isModuleDeclaration(statement) && (ts.isStringLiteral(statement.name) || statement.name.text === 'global'); +} + +// The declarations of a statement that belong to the module's scope; a +// `var` in a nested statement is also one. +function visitDeclarations(node: ts.Node, declare: (name: ts.Identifier) => void): void { + if ( + (ts.isVariableDeclaration(node) || ts.isBindingElement(node) || ts.isFunctionDeclaration(node) + || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) + || ts.isEnumDeclaration(node) || ts.isModuleDeclaration(node)) + && node.name && ts.isIdentifier(node.name) + ) { + declare(node.name); + } + if (!ts.isFunctionLikeDeclaration(node) && !ts.isClassLikeDeclaration(node) && !ts.isModuleDeclaration(node)) { + node.forEachChild(child => visitDeclarations(child, declare)); + } +} + +// The declaration emit of each file, and its signature: a hash of the +// declaration, or of the text when the file has none to compare. +function getDeclarations(api: API, project: Project, fileNames: string[]) { + const { program } = project; + const result = new Map(); + for (let i = 0; i < fileNames.length; i += CHUNK_SIZE) { + const files = api.batch(...fileNames.slice(i, i + CHUNK_SIZE).map(fileName => program.getSourceFile.gen(fileName))) + .filter((file): file is ts.SourceFile => !!file); + const emitted = files.filter(file => !file.isDeclarationFile); + const outputs = new Map(); + const collect = (outputFiles: ReturnType['outputFiles']) => { + for (const [outputName, output] of outputFiles) { + if (output.sourceFileName === undefined || outputName.endsWith('.map')) { + continue; + } + let declaration = outputs.get(output.sourceFileName); + if (!declaration) { + outputs.set(output.sourceFileName, declaration = []); + } + declaration.push({ fileName: outputName, text: output.text }); + } + }; + if (emitted.length) { + collect(program.getDeclarationEmit(emitted.map(file => file.fileName)).outputFiles); + } + // The program emits no declarations for a file it reached through + // `node_modules`, e.g. a workspace package or a referenced project's + // source behind a symlink. As root files of a program with the same + // options and references they get the declarations this program sees. + const unemitted = emitted.filter(file => !outputs.has(file.fileName)).map(file => file.fileName); + if (unemitted.length) { + const roots = api.createProgram(unemitted, program.getCompilerOptions() as any, { + projectReferences: project.parsedCommandLine.projectReferences, + }); + try { + collect(roots.getDeclarationEmit(unemitted).outputFiles); + } + finally { + roots.dispose(); + } + } + for (const file of files) { + const declaration = file.isDeclarationFile + ? [{ fileName: file.fileName, text: file.text }] + : outputs.get(file.fileName)?.sort((a, b) => a.text < b.text ? -1 : a.text > b.text ? 1 : 0); + const text = declaration?.map(output => output.text).join('\0') ?? file.text; + result.set(file.fileName, { declaration, signature: hash(text) }); + } + } + return result; +} + +// Identifiers of a source declaration that can reference a top-level name, +// and the module specifiers it names. +interface Unit { + nodes: ts.Identifier[]; + // Names referenced without the checker's help, e.g. `{ a }`. + names: Set; + specifiers: [specifier: string, key: string][]; +} + +interface Names { + names: Record; + // Keys of declarations stored without their references. + pending: string[]; +} + +// The names of each module: per export, per top-level declaration and import, +// and for `export *`. A declaration's hash is its text in the declaration +// emit; what it references is resolved by the checker in the source. Files +// whose exports can't be read this way get none (see `createGraph`). What +// declarations reference is read when the graph first needs it. +function getNames( + api: API, + project: Project, + entries: [fileName: string, declaration: Declaration[] | undefined][], + infos: Map, + cache: CacheData, +) { + const { program } = project; + const result = new Map(); + const named = entries.filter(([fileName, declaration]) => { + const info = infos.get(fileName); + const named = declaration?.length === 1 && !!info && !(info.global && !info.scope) + && !/\.(?:[cm]?jsx?|json)$/.test(fileName); + if (!named) { + result.set(fileName, undefined); + } + return named; + }); + for (let i = 0; i < named.length; i += CHUNK_SIZE) { + const chunk = named.slice(i, i + CHUNK_SIZE); + const files = api.batch(...chunk.map(([fileName]) => program.getSourceFile.gen(fileName))); + const outputs = chunk.map(([, declaration], j) => files[j]?.isDeclarationFile ? undefined : declaration![0]); + const parsed = outputs.flatMap(output => output ? [output] : []); + const leases = api.batch(...parsed.map(output => api.createSourceFile.gen(output.fileName, output.text))); + try { + const emits = new Map(parsed.map((output, j) => [output, leases[j].sourceFile])); + chunk.forEach(([fileName], j) => { + const file = files[j]; + const emit = outputs[j] ? emits.get(outputs[j]) : file; + if (!file || !emit) { + result.set(fileName, undefined); + return; + } + result.set( + fileName, + readNames(fileName, emit, collectSource(file), infos.get(fileName)!, cache.graph[fileName].deps), + ); + }); + } + finally { + api.batch(...leases.map(lease => lease.dispose.gen())); + } + } + return result; +} + +// Reads what the pending declarations of each file reference. The file is +// unchanged since its names were read. +function readPendingRefs( + api: API, + project: Project, + cache: CacheData, + ownFiles: Map, + infos: Map, + fileNames: string[], +) { + const { program, checker } = project; + const unanalyzed = fileNames.filter(fileName => !infos.has(fileName)); + for (const [fileName, info] of analyze(api, project, unanalyzed, ownFiles, undefined, false)) { + infos.set(fileName, info); + } + for (let i = 0; i < fileNames.length; i += CHUNK_SIZE) { + const chunk = fileNames.slice(i, i + CHUNK_SIZE); + const files = api.batch(...chunk.map(fileName => program.getSourceFile.gen(fileName))); + const sources = files.map(file => file && collectSource(file)); + resolveSources(api, checker, sources); + chunk.forEach((fileName, j) => { + const node = cache.graph[fileName]; + const source = sources[j]; + const info = infos.get(fileName); + if (source && info) { + readRefs(fileName, source, info, node.deps, node.names!, node.pending!); + } + else { + for (const key of node.pending!) { + node.names![key] = [node.names![key][0], ...node.deps.map((_, i) => `${i}\0${ALL}`)]; + } + } + delete node.pending; + }); + } +} + +// The top-level declarations and imports of a module's source, and the +// identifiers of each declaration that can reference one of them. Function +// bodies with a return type and initializers with a type don't reach the +// declared types, so they are skipped. +function collectSource(file: ts.SourceFile) { + const units = new Map(); + const imports = new Map(); + const bindings: ts.Identifier[] = []; + const attributed = new Set(); + const newUnit = (): Unit => ({ nodes: [], names: new Set(), specifiers: [] }); + // Other statements, e.g. `f.x = 1`, which adds to `f`'s declaration. + const shared = newUnit(); + let assignment: Unit | undefined; + const walks: [ts.Node, Unit][] = []; + const declare = (name: string, unit: Unit, node?: ts.Identifier) => { + if (node) { + bindings.push(node); + } + let list = units.get(name); + if (!list) { + units.set(name, list = []); + } + list.push(unit); + }; + const declareBindings = (name: ts.BindingName, unit: Unit) => { + if (ts.isIdentifier(name)) { + declare(name.text, unit, name); + return; + } + for (const element of name.elements) { + if (!ts.isOmittedExpression(element) && element.name) { + declareBindings(element.name, unit); + } + } + }; + for (const statement of file.statements) { + if (isBlock(statement) || ts.isNamespaceExportDeclaration(statement)) { + continue; + } + if (ts.isImportDeclaration(statement)) { + attributed.add(statement.moduleSpecifier); + const clause = statement.importClause; + if (!clause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const specifier = statement.moduleSpecifier.text; + if (clause.name) { + bindings.push(clause.name); + imports.set(clause.name.text, [specifier, 'default']); + } + const namedBindings = clause.namedBindings; + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + bindings.push(namedBindings.name); + imports.set(namedBindings.name.text, [specifier, ALL]); + } + else if (namedBindings) { + for (const element of namedBindings.elements) { + bindings.push(element.name); + imports.set(element.name.text, [specifier, (element.propertyName ?? element.name).text]); + } + } + } + else if (ts.isImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + if (ts.isExternalModuleReference(reference)) { + attributed.add(reference.expression); + bindings.push(statement.name); + if (ts.isStringLiteral(reference.expression)) { + imports.set(statement.name.text, [reference.expression.text, ALL]); + } + } + else { + const unit = newUnit(); + declare(statement.name.text, unit, statement.name); + walks.push([reference, unit]); + } + } + else if (ts.isExportDeclaration(statement)) { + if (statement.moduleSpecifier) { + attributed.add(statement.moduleSpecifier); + } + } + else if (ts.isExportAssignment(statement)) { + // `export default a` is read from the declaration emit. + if (!ts.isIdentifier(statement.expression)) { + walks.push([statement.expression, assignment ??= newUnit()]); + } + } + else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + const unit = newUnit(); + declareBindings(declaration.name, unit); + walks.push([declaration, unit]); + } + } + else if ( + ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement) + ) { + const unit = newUnit(); + const name = statement.name && ts.isIdentifier(statement.name) ? statement.name : undefined; + declare(name?.text ?? 'default', unit, name); + walks.push([statement, unit]); + } + else { + visitDeclarations(statement, name => declare(name.text, shared, name)); + walks.push([statement, shared]); + } + } + + return { file, units, imports, shared, assignment, attributed, bindings, walks, nodes: [] as ts.Identifier[] }; +} + +type Source = ReturnType; + +// Turns the declarations of a source into the identifiers that can reference +// a top-level name, and resolves them. +function resolveSources(api: API, checker: Checker, sources: (Source | undefined)[]) { + const requests: [Source, [ts.Identifier, Unit][]][] = []; + for (const source of sources) { + if (source) { + const candidates = walkSource(source); + if (source.nodes.length) { + requests.push([source, candidates]); + } + } + } + api.batch(...requests.map(([source]) => checker.getSymbolAtLocation.gen(source.nodes))).forEach((symbols, i) => { + resolveUnits(...requests[i], symbols); + }); +} + +function walkSource(source: Source) { + const { units, imports, bindings, attributed, walks } = source; + const topLevel = new Set([...units.keys(), ...imports.keys()]); + const candidates: [ts.Identifier, Unit][] = []; + const walk = (node: ts.Node, unit: Unit): void => { + if (ts.isIdentifier(node)) { + if (topLevel.has(node.text)) { + const parent = node.parent as ts.Node & { name?: ts.Node; propertyName?: ts.Node; label?: ts.Node }; + if (ts.isShorthandPropertyAssignment(parent) || ts.isExportSpecifier(parent)) { + // The checker resolves these to the property or the export. + if (ts.isShorthandPropertyAssignment(parent) || node === (parent.propertyName ?? parent.name)) { + unit.names.add(node.text); + } + } + else if (parent.name !== node && parent.propertyName !== node && parent.label !== node) { + candidates.push([node, unit]); + } + } + return; + } + if ( + ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal) + ) { + attributed.add(node.argument.literal); + let qualifier = node.qualifier; + while (qualifier && ts.isQualifiedName(qualifier)) { + qualifier = qualifier.left; + } + unit.specifiers.push([node.argument.literal.text, qualifier?.text ?? ALL]); + node.typeArguments?.forEach(child => walk(child, unit)); + return; + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteral(argument)) { + attributed.add(argument); + unit.specifiers.push([argument.text, ALL]); + } + } + if (ts.isPropertyAccessExpression(node)) { + walk(node.expression, unit); + return; + } + if (ts.isQualifiedName(node)) { + walk(node.left, unit); + return; + } + let skipped: ts.Node | undefined; + if (ts.isFunctionLikeDeclaration(node) && (node.type || ts.isSetAccessorDeclaration(node))) { + skipped = node.body; + } + else if ( + (ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isParameterDeclaration(node)) + && node.type + ) { + skipped = node.initializer; + } + node.forEachChild(child => { + if (child !== skipped) { + walk(child, unit); + } + }); + }; + for (const [node, unit] of walks) { + walk(node, unit); + } + for (const [node, unit] of candidates) { + unit.nodes.push(node); + } + source.nodes = [...bindings, ...candidates.map(([node]) => node)]; + return candidates; +} + +// Turns the identifiers of each unit into the names they resolve to. One the +// checker resolves to nothing, or whose name's declarations it doesn't +// resolve, counts as every top-level name of its text. +function resolveUnits( + source: Source, + candidates: [ts.Identifier, Unit][], + symbols: readonly ({ id: number } | undefined)[], +) { + const byId = new Map(); + const unresolved = new Set(); + source.bindings.forEach((node, i) => { + const symbol = symbols[i]; + if (symbol) { + byId.set(symbol.id, node.text); + } + else { + unresolved.add(node.text); + } + }); + candidates.forEach(([node, unit], i) => { + const symbol = symbols[source.bindings.length + i]; + const name = symbol && byId.get(symbol.id); + if (name !== undefined) { + unit.names.add(name); + } + else if (!symbol || unresolved.has(node.text)) { + unit.names.add(node.text); + } + }); +} + +// The names of a module with their hashes, and what its exports, imports +// and `export *` reference. Declarations get their references from +// `readRefs`; their keys are returned as pending. +function readNames( + fileName: string, + emit: ts.SourceFile, + source: Source, + info: FileInfo, + deps: string[], +): Names { + const encode = (specifier: string, key: string) => encodeRef(fileName, info.references, deps, specifier, key); + // A key that names the same target in any run, unlike the dep index. + const describe = (specifier: string, key: string) => `${info.references.get(specifier) ?? '?' + specifier}\0${key}`; + + const texts = new Map(); + const exports = new Map>(); + const stars: string[] = []; + const imports = new Map(source.imports); + const addText = (name: string, text: string) => { + let list = texts.get(name); + if (!list) { + texts.set(name, list = []); + } + list.push(text); + }; + const addExport = (name: string, ...refs: (string | undefined)[]) => { + let set = exports.get(name); + if (!set) { + exports.set(name, set = new Set()); + } + for (const ref of refs) { + if (ref !== undefined) { + set.add(ref); + } + } + }; + for (const statement of emit.statements) { + const modifiers = (statement as { modifiers?: ts.NodeArray }).modifiers; + const isExported = !!modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword); + const isDefault = !!modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword); + const text = emit.text.slice(statement.pos, statement.end); + if (isBlock(statement)) { + continue; + } + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause; + if (!clause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const specifier = statement.moduleSpecifier.text; + if (clause.name && !imports.has(clause.name.text)) { + imports.set(clause.name.text, [specifier, 'default']); + } + const namedBindings = clause.namedBindings; + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + if (!imports.has(namedBindings.name.text)) { + imports.set(namedBindings.name.text, [specifier, ALL]); + } + } + else if (namedBindings) { + for (const element of namedBindings.elements) { + if (!imports.has(element.name.text)) { + imports.set(element.name.text, [specifier, (element.propertyName ?? element.name).text]); + } + } + } + } + else if (ts.isImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + if (ts.isExternalModuleReference(reference)) { + if (ts.isStringLiteral(reference.expression) && !imports.has(statement.name.text)) { + imports.set(statement.name.text, [reference.expression.text, ALL]); + } + } + else { + addText(statement.name.text, text); + } + if (isExported) { + addExport(statement.name.text, LOCAL + statement.name.text); + } + } + else if (ts.isVariableStatement(statement)) { + const list = statement.declarationList.declarations; + const prefix = list.length ? emit.text.slice(statement.pos, list[0].pos) : ''; + for (const declaration of list) { + const names: string[] = []; + const collect = (name: ts.BindingName) => { + if (ts.isIdentifier(name)) { + names.push(name.text); + return; + } + for (const element of name.elements) { + if (!ts.isOmittedExpression(element) && element.name) { + collect(element.name); + } + } + }; + collect(declaration.name); + for (const name of names) { + addText(name, prefix + emit.text.slice(declaration.pos, declaration.end)); + if (isExported) { + addExport(name, LOCAL + name); + } + } + } + } + else if ( + ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement) + ) { + const name = statement.name && ts.isIdentifier(statement.name) ? statement.name.text : 'default'; + addText(name, text); + if (isExported) { + addExport(isDefault ? 'default' : name, LOCAL + name); + } + } + else if (ts.isExportDeclaration(statement)) { + const specifier = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + const clause = statement.exportClause; + if (!clause) { + if (specifier !== undefined) { + stars.push(specifier); + } + } + else if (ts.isNamespaceExport(clause)) { + if (specifier !== undefined) { + addExport(clause.name.text, encode(specifier, ALL)); + } + } + else { + for (const element of clause.elements) { + const local = (element.propertyName ?? element.name).text; + addExport(element.name.text, specifier === undefined ? LOCAL + local : encode(specifier, local)); + } + } + } + else if (ts.isExportAssignment(statement)) { + const name = statement.isExportEquals ? 'export=' : 'default'; + if (ts.isIdentifier(statement.expression)) { + addExport(name, LOCAL + statement.expression.text); + } + else { + addText(name, text); + addExport(name, LOCAL + name); + } + } + } + + const names = new Map(); + const pending: string[] = []; + for (const name of new Set([...texts.keys(), ...source.units.keys()])) { + if (!imports.has(name)) { + names.set(LOCAL + name, [hash(texts.get(name)?.join('\0') ?? '')]); + pending.push(LOCAL + name); + } + } + for (const [name, [specifier, key]] of imports) { + const ref = encode(specifier, key); + names.set(LOCAL + name, [hash(describe(specifier, key)), ...ref === undefined ? [] : [ref]]); + } + for (const [name, refs] of exports) { + // Refs within the file are the same in any run. + const describeRef = (ref: string) => { + const separator = ref.indexOf('\0'); + return separator <= 0 ? ref : `${deps[+ref.slice(0, separator)]}\0${ref.slice(separator + 1)}`; + }; + names.set(name, [hash([...refs].map(describeRef).sort().join('\0')), ...refs]); + } + if (stars.length) { + const refs = stars.map(specifier => encode(specifier, ALL)).filter(ref => ref !== undefined); + names.set(STAR, [hash(stars.map(specifier => describe(specifier, ALL)).sort().join('\0')), ...new Set(refs)]); + } + return { names: Object.fromEntries(names), pending }; +} + +// What each of the given declarations of a module references, from its +// resolved source. +function readRefs( + fileName: string, + source: Source, + info: FileInfo, + deps: string[], + names: Record, + keys: string[], +) { + const { file } = source; + const encode = (specifier: string, key: string) => encodeRef(fileName, info.references, deps, specifier, key); + const addUnit = (unit: Unit, refs: Set) => { + for (const name of unit.names) { + refs.add(LOCAL + name); + } + for (const [specifier, key] of unit.specifiers) { + const ref = encode(specifier, key); + if (ref !== undefined) { + refs.add(ref); + } + } + }; + // What every declaration references: the other statements, and module + // specifiers and references no declaration accounts for. + const shared = new Set(); + addUnit(source.shared, shared); + for (const ref of [...file.typeReferenceDirectives, ...file.referencedFiles]) { + const encoded = encode(ref.fileName, ALL); + if (encoded !== undefined) { + shared.add(encoded); + } + } + for (const specifier of file.imports as unknown as ts.StringLiteral[]) { + if (!source.attributed.has(specifier)) { + const ref = encode(specifier.text, ALL); + if (ref !== undefined) { + shared.add(ref); + } + } + } + // A declaration the emit adds, e.g. `_default` for `export default f()`, + // references what `export default` / `export =` does, or, without + // one, every dep. + const emitted = new Set(); + if (source.assignment) { + addUnit(source.assignment, emitted); + } + else { + deps.forEach((_, i) => emitted.add(`${i}\0${ALL}`)); + } + for (const key of keys) { + const refs = new Set(shared); + const units = source.units.get(key.slice(LOCAL.length)); + if (units) { + for (const unit of units) { + addUnit(unit, refs); + } + } + else { + for (const ref of emitted) { + refs.add(ref); + } + } + refs.delete(key); + names[key] = [names[key][0], ...refs]; + } +} + +function updateContentMappersHash(api: API, hash: crypto.Hash, configFile: string) { + const config = api.readConfigFile(configFile).config as { contentMappers?: unknown } | undefined; + if (!Array.isArray(config?.contentMappers)) { + return; + } + for (const mapper of config.contentMappers) { + const name = (mapper as { package?: unknown } | undefined)?.package; + if (typeof name !== 'string') { + continue; + } + let packageJsonPath: string; + try { + packageJsonPath = require.resolve(`${name}/package.json`, { paths: [path.dirname(configFile)] }); + } + catch { + hash.update(`\0${name}\0missing`); + continue; + } + const packageJson = readFile(packageJsonPath); + hash.update(`\0${packageJsonPath}\0${packageJson}`); + updateLockfileHash(hash, path.dirname(packageJsonPath)); + // Files the mapper runs, e.g. `"exec": ["node", "server.js"]`. + let exec: unknown; + try { + exec = JSON.parse(packageJson)?.typescript?.contentMapper?.exec; + } + catch {} + if (!Array.isArray(exec)) { + continue; + } + for (const arg of exec) { + if (typeof arg !== 'string') { + continue; + } + const fileName = path.resolve(path.dirname(packageJsonPath), arg); + const stat = fs.statSync(fileName, { throwIfNoEntry: false }); + if (stat?.isFile()) { + hash.update(`\0${fileName}\0${stat.mtimeMs}\0${stat.size}`); + } + } + } +} + +const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'bun.lock', 'bun.lockb']; + +// The mapper's own dependencies (e.g. `@vue/language-core`) are not in the +// program, so an upgrade is seen through the nearest lockfile above it. +function updateLockfileHash(hash: crypto.Hash, dir: string) { + while (true) { + let found = false; + for (const name of LOCKFILES) { + const fileName = path.join(dir, name); + const stat = fs.statSync(fileName, { throwIfNoEntry: false }); + if (stat?.isFile()) { + hash.update(`\0${fileName}\0${stat.mtimeMs}\0${stat.size}`); + found = true; + } + } + const parent = path.dirname(dir); + if (found || parent === dir) { + return; + } + dir = parent; + } +} + +export function hashFile(fileName: string, hashes: Map) { + let fileHash = hashes.get(fileName); + if (fileHash === undefined) { + hashes.set(fileName, fileHash = hash(readFile(fileName))); + } + return fileHash; +} + +function readFile(fileName: string) { + try { + return fs.readFileSync(fileName, 'utf8'); + } + catch { + return ''; + } +} + +// 96 bits; the cache keeps one per name. +function hash(text: string) { + return crypto.createHash('sha256').update(text).digest('base64url').slice(0, 16); +} diff --git a/packages/cli/lib/languagePlugins.ts b/packages/cli/lib/languagePlugins.ts deleted file mode 100644 index 6e2ce88c..00000000 --- a/packages/cli/lib/languagePlugins.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { LanguagePlugin } from '@volar/language-core'; -import { - createAstroPlugin, - createMdxPlugin, - createTsMacroPlugins, - createVuePlugin, - createVueVinePlugins, -} from '@volar/language-hub'; -import ts = require('typescript'); - -const cache = new Map[]>(); - -export async function load(tsconfig: string, languages: string[]) { - if (cache.has(tsconfig)) { - return cache.get(tsconfig)!; - } - const plugins: LanguagePlugin[] = []; - if (languages.includes('vue')) { - plugins.push(createVuePlugin(ts, tsconfig)); - } - if (languages.includes('vue-vine')) { - plugins.push(...createVueVinePlugins(ts, tsconfig)); - } - if (languages.includes('mdx')) { - plugins.push(await createMdxPlugin(ts, tsconfig)); - } - if (languages.includes('astro')) { - plugins.push(createAstroPlugin(ts, tsconfig)); - } - if (languages.includes('ts-macro')) { - plugins.push(...await createTsMacroPlugins(ts, tsconfig)); - } - cache.set(tsconfig, plugins); - return plugins; -} diff --git a/packages/cli/lib/render.ts b/packages/cli/lib/render.ts index 1bc32e8b..452a2324 100644 --- a/packages/cli/lib/render.ts +++ b/packages/cli/lib/render.ts @@ -46,7 +46,7 @@ export function createRenderer(): Renderer { for (const line of lines) { process.stdout.write(line + '\n'); } - // Mark as content so any follow-up `info()` (e.g. --list-rules) + // Mark as content so any follow-up `info()` (e.g. --debug-estree) // gets a blank-line separator in TTY mode. Without this, the // separator only appears when diagnostics happened to print // before summary, making the gap inconsistent across runs. diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 825e308b..ce95bbe7 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -1,143 +1,73 @@ -import ts = require('typescript'); import type config = require('@tsslint/config'); import core = require('@tsslint/core'); import url = require('url'); import path = require('path'); import fs = require('fs'); -import crypto = require('crypto'); -import languagePlugins = require('./languagePlugins.js'); +import * as ts from 'typescript/ast'; +import { API, type Project, type Snapshot } from 'typescript/sync'; import cacheFlow = require('./cache-flow.js'); -import incrementalState = require('./incremental-state.js'); -import type { FileCache } from './cache.js'; -import type { IncrementalState } from './incremental-state.js'; +import contentMapper = require('./content-mapper.js'); +import type { LintTarget } from '@tsslint/types'; +import type { CacheData } from './cache.js'; +import { getConfigModulesKey, trackImports } from './config-modules.js'; +import { hashFile, invalidate } from './invalidation.js'; + +// One TS7 API process and one snapshot holding every project of the run. +// Projects share parsed files inside the snapshot; `--fix` replaces the +// snapshot after each write. +let api: API | undefined; +let snapshot: Snapshot | undefined; + +let tsconfig: string; +let linter: core.Linter; +let cache: CacheData; +// Text hashes of program files, read at most once per run. +const fileHashes = new Map(); + +export function getApi(): API { + // Content mappers run external processes declared by the project's + // tsconfig; the project's own tsslint.config.ts is equally trusted. + return api ??= new API({ cwd: process.cwd(), runExternalCode: true }); +} -// Fallback if `ts.sys.createHash` is undefined on this host (Node ≥ 22.6 -// always provides it via crypto, but the type is optional). sha256 hex. -const defaultHash = (s: string) => crypto.createHash('sha256').update(s).digest('hex'); +export function openProjects(tsconfigs: string[]) { + snapshot = getApi().createSnapshot({ openProjects: tsconfigs }); +} -import { createLanguage, FileMap, isCodeActionsEnabled, type Language } from '@volar/language-core'; -import { createProxyLanguageService, decorateLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript'; -import { transformDiagnostic, transformFileTextChanges } from '@volar/typescript/lib/node/transform'; +export function getProjectFileNames(tsconfig: string): readonly string[] { + return getProject(tsconfig).parsedCommandLine.fileNames; +} -let projectVersion = 0; -let typeRootsVersion = 0; -let options: ts.CompilerOptions = {}; -let fileNames: string[] = []; -let language: Language | undefined; -let linter: core.Linter; -let linterLanguageService!: ts.LanguageService; -// Layer 2 state. We wrap the LS program in a SemanticDiagnostics- -// BuilderProgram (with the prev session's BP fed back via TS's internal -// `tsBuildInfoText` round-trip) and walk affected files once. cache- -// flow consults this set to decide whether type-aware rules can be -// cache-hit. Always populated under the CLI; `--force` opts out by -// clearing the loaded cache, not by disabling layer 2. -let affectedFiles: Set | undefined; -// The current session's BP — held until end-of-project so we can -// capture its updated buildinfo text for next session's persistence. -let currentBuilder: ts.SemanticDiagnosticsBuilderProgram | undefined; +export function close() { + snapshot?.dispose(); + api?.close(); +} -const snapshots = new Map(); -const versions = new Map(); -const originalHost: ts.LanguageServiceHost = { - ...ts.sys, - useCaseSensitiveFileNames() { - return ts.sys.useCaseSensitiveFileNames; - }, - getProjectVersion() { - return projectVersion.toString(); - }, - getTypeRootsVersion() { - return typeRootsVersion; - }, - getCompilationSettings() { - return options; - }, - getScriptFileNames() { - return fileNames; - }, - getScriptVersion(fileName) { - // In-session bumps win — `--fix` updates this map after writing - // the file. Otherwise fall back to the on-disk mtime so the - // version reflects content across CLI invocations. Layer 2's - // BuilderProgram diff relies on this — without it, every cross- - // session file looks unchanged (always '0') even when the - // content moved on disk. - const inSession = versions.get(fileName); - if (inSession !== undefined) return inSession.toString(); - const stat = fs.statSync(fileName, { throwIfNoEntry: false }); - return stat ? stat.mtimeMs.toString() : '0'; - }, - getScriptSnapshot(fileName) { - if (!snapshots.has(fileName)) { - snapshots.set(fileName, ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)!)); - } - return snapshots.get(fileName); - }, - getScriptKind(fileName) { - const languageId = resolveFileLanguageId(fileName); - switch (languageId) { - case 'javascript': - return ts.ScriptKind.JS; - case 'javascriptreact': - return ts.ScriptKind.JSX; - case 'typescript': - return ts.ScriptKind.TS; - case 'typescriptreact': - return ts.ScriptKind.TSX; - case 'json': - return ts.ScriptKind.JSON; - } - return ts.ScriptKind.Unknown; - }, - getDefaultLibFileName(options) { - return ts.getDefaultLibFilePath(options); - }, -}; -const linterHost: ts.LanguageServiceHost = { ...originalHost }; -const originalService = ts.createLanguageService(linterHost); +function getProject(configFileName = tsconfig): Project { + const project = snapshot!.getConfiguredProject(configFileName); + if (!project) { + throw new Error(`Project not loaded: ${configFileName}`); + } + return project; +} -// Linter is single-threaded by design. The previous version split into a -// worker_threads worker for TTY mode (so the spinner could update during a -// file's lint) and a local fallback for non-TTY. Real numbers showed worker -// IPC overhead (JSON.stringify + JSON.parse + structured-clone of diagnostic -// payloads + Worker spawn / teardown) wasn't earning its keep — and a single -// `text` field on a 3 MB checker.ts duplicated across hundreds of diagnostics -// blew JSON.stringify past V8's max string length, crashing the worker. -// Keep a single in-process API; the spinner just updates between files. -export function create() { - return { - setup(...args: Parameters) { - return setup(...args); - }, - lint(...args: Parameters) { - return lint(...args); - }, - hasCodeFixes(...args: Parameters) { - return hasCodeFixes(...args); - }, - hasRules(...args: Parameters) { - return hasRules(...args); - }, - getTypeAwareRules() { - return [...linter.getTypeAwareRules()]; - }, - buildIncrementalState() { - return buildIncrementalState(); - }, - }; +function getTarget(fileName: string): LintTarget | undefined { + const { program, checker } = getProject(); + const file = program.getSourceFile(fileName); + return file && { program, checker, file }; } -async function setup( - tsconfig: string, - languages: string[], +export function getSourceFile(fileName: string) { + return getProject().program.getSourceFile(fileName); +} + +export async function setup( configFile: string, - _fileNames: string[], - _options: ts.CompilerOptions, - initialTypeAwareRules: readonly string[], - prevIncrementalState: IncrementalState | undefined, + _tsconfig: string, + cacheData: CacheData, ): Promise { let config: config.Config | config.Config[]; + trackImports(); try { config = (await import(url.pathToFileURL(configFile).toString())).default; } @@ -148,280 +78,110 @@ async function setup( return String(err); } - for (let key in linterHost) { - if (!(key in originalHost)) { - // @ts-ignore - delete linterHost[key]; - } - else { - // @ts-ignore - linterHost[key] = originalHost[key]; - } - } - linterLanguageService = originalService; - language = undefined; - - // Reset per-project state. Multi-project runs reuse the same worker - // (in-process) — without this, cross-project file paths accumulate in - // `snapshots` / `versions` (memory leak) and `affectedFiles` from a - // prior project would mis-classify this project's files as cache-hit - // candidates if their absolute paths happened to overlap. - snapshots.clear(); - versions.clear(); - affectedFiles = undefined; - currentBuilder = undefined; - - const plugins = await languagePlugins.load(tsconfig, languages); - if (plugins.length) { - const { getScriptSnapshot } = originalHost; - language = createLanguage( - [ - ...plugins, - { getLanguageId: fileName => resolveFileLanguageId(fileName) }, - ], - new FileMap(ts.sys.useCaseSensitiveFileNames), - fileName => { - const snapshot = getScriptSnapshot(fileName); - if (snapshot) { - language!.scripts.set(fileName, snapshot); - } - }, - ); - decorateLanguageServiceHost(ts, language, linterHost); - - const proxy = createProxyLanguageService(linterLanguageService); - proxy.initialize(language); - linterLanguageService = proxy.proxy; - } - - projectVersion++; - typeRootsVersion++; - fileNames = _fileNames; - // Internal API path: BuilderProgram.emitBuildInfo only produces - // content when these options are set. Override the user's values - // (their own tsc --incremental builds shouldn't share this file). - // The synthetic path is never written to disk — captured via - // writeFile callback at end of session. - options = { - ...(plugins.some(plugin => plugin.typescript?.extraFileExtensions.length) - ? { ..._options, allowNonTsExtensions: true } - : _options), - incremental: true, - tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, - }; + tsconfig = _tsconfig; linter = core.createLinter( - { - languageService: linterLanguageService, - languageServiceHost: linterHost, - typescript: ts, - }, + { typescript: ts }, path.dirname(configFile), config, - () => [], - initialTypeAwareRules, ); + cache = cacheData; + invalidate(getApi(), getProject(), cache, getConfigModulesKey(configFile), fileHashes); + return true; +} - { - const program = linterLanguageService.getProgram()!; - // Reconstruct the prev session's BP from cached buildinfo text, - // fall through to undefined on any failure (cold-start path). - const oldBuilder = incrementalState.reconstructOldBuilder(ts, prevIncrementalState, { - useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, - getCurrentDirectory: () => ts.sys.getCurrentDirectory(), - }); - currentBuilder = ts.createSemanticDiagnosticsBuilderProgram( - program, - { createHash: ts.sys.createHash ?? defaultHash }, - oldBuilder as ts.SemanticDiagnosticsBuilderProgram | undefined, - ); - affectedFiles = new Set(); - // Drain via `ignoreSourceFile` to record affected files without - // computing their semantic diagnostics. The diagnostic compute is - // the expensive part of the drain (~38s on Dify cold) — TSSLint's - // own lint pass triggers semantic checks lazily for the symbols - // type-aware rules query, not the full program. Doing it twice - // wasted time. The graph-propagation work (which determines - // affected via reference graph) still runs internally. - // `ignoreSourceFile`'s typed param is SourceFile only, but TS - // internally calls it with the same `affected` value the iterator - // returns — which can also be a Program (whole-program affected - // path, e.g. lib flip). Handle both shapes at runtime via the - // `fileName` discriminator. - const recordAffected = (sf: ts.SourceFile) => { - const a = sf as ts.SourceFile | ts.Program; - if ('fileName' in a) { - affectedFiles!.add(a.fileName); +function hashFiles(fileNames: readonly string[]) { + const result: Record = {}; + for (let fileName of fileNames) { + if (!cache.graph[fileName]) { + // A name the rule passed to the API, or a file outside the graph + // (`node_modules`, lib files), which the global key covers. + const file = getProject().program.getSourceFile(fileName); + if (!file || !cache.graph[file.fileName]) { + continue; } - else { - for (const f of a.getSourceFiles()) affectedFiles!.add(f.fileName); - } - return true; - }; - while (true) { - const result = currentBuilder.getSemanticDiagnosticsOfNextAffectedFile( - undefined, - recordAffected, - ); - if (!result) break; - // Should not reach here — `ignoreSourceFile` always returns true. + fileName = file.fileName; } + result[fileName] = hashFile(fileName, fileHashes); } - - return true; -} - -// Capture the current session's BP state for persistence. Called by -// the CLI at end of project. Returns undefined when not in incremental -// mode or when capture fails. -function buildIncrementalState(): IncrementalState | undefined { - if (!currentBuilder) return undefined; - return incrementalState.captureIncrementalState(ts.version, currentBuilder); + return result; } -function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { - let newSnapshot: ts.IScriptSnapshot | undefined; - let diagnostics!: ts.DiagnosticWithLocation[]; - let shouldCheck = true; - - // Layer 2 signals. `incremental` is always true under the CLI now — - // `--force` opts out by clearing the loaded cache instead. - // typeAwareUnaffected: file's deps haven't moved since prev session, - // so cached type-aware entries can be reused this run. - // False in --fix mode — fixes mutate files mid-session - // and invalidate the setup-time affected snapshot for - // downstream files; we'd rather re-run than serve stale. - const typeAwareUnaffected = !fix && !affectedFiles!.has(fileName); +export function lint(fileName: string, fix: boolean) { + let target = getTarget(fileName); + if (!target) { + return []; + } if (fix) { - // Drop cache entries for rules that registered a fix in any prior - // session — we need to actually run those rules now to rebuild the - // `getEdits` callbacks (closures don't survive the JSON cache). - // Rules with no fixes can stay cached. - for (const ruleId of Object.keys(fileCache.rules)) { - if (fileCache.rules[ruleId].hasFix) { - delete fileCache.rules[ruleId]; - } - } // Iterate to a fixed point so chained autofixes complete in one // CLI run. Example: `var x = 1` (never reassigned) needs no-var // to rewrite to `let x = 1` before prefer-const can fire and // rewrite to `const x = 1` — without iteration, the user has to // run `--fix` repeatedly. ESLint caps at 10 passes; match that. const MAX_FIX_PASSES = 10; - let pass = 0; - let converged = false; - for (; pass < MAX_FIX_PASSES; pass++) { - const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { - incremental: true, - typeAwareUnaffected, - }); - - let fixes = linter + for (let pass = 0;; pass++) { + const diagnostics = cacheFlow.lintWithCache(linter, target, cache.files, { fix: true, hashFiles }); + const file = target.file; + // A mapper can copy original code more than once (e.g. Vue's + // navigation-only copies), so one edit can arrive once per copy. + const seenEdits = new Set(); + const fixes = linter .getCodeFixes(fileName, 0, Number.MAX_VALUE, diagnostics) - .filter(fix => fix.fixId === 'tsslint'); - - if (language) { - fixes = fixes.map(fix => { - fix.changes = transformFileTextChanges(language!, fix.changes, false, isCodeActionsEnabled); - return fix; + .filter(fix => fix.fixId === 'tsslint') + .flatMap(fix => { + const edits = contentMapper.toOriginalTextEdits(file, fix.edits); + if (!edits) { + return []; + } + const key = JSON.stringify(edits); + if (seenEdits.has(key)) { + return []; + } + seenEdits.add(key); + return [{ ...fix, edits }]; }); + const edits = core.combineCodeFixes(fixes); + if (!edits.length) { + return diagnostics; } - - const textChanges = core.combineCodeFixes(fileName, fixes); - if (!textChanges.length) { - converged = true; - break; + if (pass === MAX_FIX_PASSES) { + // Two rules' fixes conflict (each pass undoes the other) or a + // single rule oscillates. Surface it; staying silent leaves the + // user thinking --fix succeeded when partial fixes remain. + // Mirrors ESLint's "Maximum autofix passes exceeded" warning. + console.warn( + `[tsslint] --fix did not converge on ${fileName} after ${MAX_FIX_PASSES} passes; remaining fixable diagnostics may indicate a rule conflict.`, + ); + return diagnostics; } - - const oldSnapshot = snapshots.get(fileName)!; - newSnapshot = core.applyTextChanges(oldSnapshot, textChanges); - snapshots.set(fileName, newSnapshot); - versions.set(fileName, (versions.get(fileName) ?? 0) + 1); - projectVersion++; - - // Wipe rule cache so the next pass re-lints the new snapshot. - // (cacheFlow keys on mtime, but disk hasn't been written yet — - // the post-loop block does the single converged write.) - fileCache.rules = {}; - } - if (!converged) { - // Hit MAX_FIX_PASSES without the fix loop settling — likely two - // rules' fixes conflict (each pass undoes the other) or a single - // rule oscillates. Surface it; staying silent leaves the user - // thinking --fix succeeded when partial fixes remain. Mirrors - // ESLint's "Maximum autofix passes exceeded" warning. - console.warn( - `[tsslint] --fix did not converge on ${fileName} after ${MAX_FIX_PASSES} passes; remaining fixable diagnostics may indicate a rule conflict.`, + fs.writeFileSync( + fileName, + core.applyTextEdits(contentMapper.getOriginalText(target.file), edits), ); - } - shouldCheck = false; - } - - if (newSnapshot) { - const newText = newSnapshot.getText(0, newSnapshot.getLength()); - const oldText = ts.sys.readFile(fileName); - if (newText !== oldText) { - ts.sys.writeFile(fileName, newSnapshot.getText(0, newSnapshot.getLength())); - // File content moved — refresh mtime so the next lint pass - // invalidates layer-1 cache entries for this file. lintWithCache - // compares fileCache.mtime against the fileMtime we pass in. - fileMtime = fs.statSync(fileName).mtimeMs; - shouldCheck = true; - } - } - - if (shouldCheck) { - const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { - incremental: true, - typeAwareUnaffected, - }); - } - - // Language-transform path (Vue/MDX/etc.): diagnostics map back from - // the transformed file to the original source. The original file - // might not be in the language service's program, so we substitute a - // SourceFile-shaped POJO with the real source text — `formatDiagnostics- - // WithColorAndContext` reads `.file.text` to render code snippets. - if (language) { - diagnostics = diagnostics - .map(d => transformDiagnostic(language!, d, (originalService as any).getCurrentProgram(), false)) - .filter(d => !!d); - const fileShim = new Map(); - const getShim = (fn: string) => { - let s = fileShim.get(fn); - if (!s) { - s = { fileName: fn, text: getFileText(fn) }; - fileShim.set(fn, s); + delete cache.files[fileName]; + fileHashes.delete(fileName); + // Next run sees the file as changed and compares its signature + // against the one from before the fix, so importers linted + // earlier in this run are re-checked. + if (cache.graph[fileName]) { + cache.graph[fileName].mtime = -1; } - return s; - }; - diagnostics = diagnostics.map(d => ({ - ...d, - file: getShim(d.file.fileName) as any, - relatedInformation: d.relatedInformation?.map(info => ({ - ...info, - file: info.file ? getShim(info.file.fileName) as any : undefined, - })), - })); + const prev = snapshot!; + // Listing the project again rebuilds it; otherwise it stays dirty + // and keeps serving the old program. + snapshot = prev.update({ fileNotifications: { changed: [fileName] }, openProjects: [tsconfig] }); + prev.dispose(); + target = getTarget(fileName)!; + } } - // Plain-TS path: leave diagnostics as-is. `.file` is the program's real - // `ts.SourceFile` which already shares `lineMap` cache across all - // diagnostics on the same file (so `formatDiagnosticsWithColorAndContext` - // only computes line starts once per file). - - return diagnostics; -} -function getFileText(fileName: string) { - return originalHost.getScriptSnapshot(fileName)!.getText(0, Number.MAX_VALUE); + return cacheFlow.lintWithCache(linter, target, cache.files, { hashFiles }); } -function hasCodeFixes(fileName: string) { +export function hasCodeFixes(fileName: string) { return linter.hasCodeFixes(fileName); } -function hasRules(fileName: string) { +export function hasRules(fileName: string) { return Object.keys(linter.getRules(fileName)).length > 0; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 86e410e2..d978387c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,9 +1,9 @@ { "name": "@tsslint/cli", - "version": "3.1.4", + "version": "4.0.0-alpha.1", "license": "MIT", "engines": { - "node": ">=22.6.0" + "node": ">=22.15.0" }, "bin": { "tsslint": "./bin/tsslint.js" @@ -21,15 +21,10 @@ "directory": "packages/cli" }, "dependencies": { - "@tsslint/config": "3.1.4", - "@tsslint/core": "3.1.4", - "@tsslint/types": "3.1.4", - "@volar/language-core": "~2.4.0", - "@volar/language-hub": "0.0.1", - "@volar/typescript": "~2.4.0", - "minimatch": "^10.0.1" - }, - "peerDependencies": { - "typescript": "*" + "@tsslint/config": "4.0.0-alpha.1", + "@tsslint/core": "4.0.0-alpha.1", + "@tsslint/types": "4.0.0-alpha.1", + "minimatch": "^10.0.1", + "typescript": "7.1.0-beta" } } diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index 318f5f2e..584b1871 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -1,12 +1,12 @@ -// Layer 1 cache-flow tests. These cover the cache-aware lint pass — -// every invariant of the CLI's per-file mtime / per-rule cache. +// Cache-flow tests. These cover the cache-aware lint pass — every +// invariant of the CLI's per-file cache entries. // // Run via: // node packages/cli/test/cache-flow.test.js import type { Config, RuleContext } from '@tsslint/types'; -import * as ts from 'typescript'; import type { FileCache } from '../lib/cache.js'; +import { createProject } from './project'; const core = require('@tsslint/core') as typeof import('@tsslint/core'); const cacheFlow = require('../lib/cache-flow.js') as typeof import('../lib/cache-flow.js'); @@ -22,269 +22,40 @@ function check(name: string, cond: boolean, detail?: string) { } } -function makeContext(files: Record) { - const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); - const realLibContent = ts.sys.readFile(realLibPath) ?? ''; - const host: ts.LanguageServiceHost = { - getCompilationSettings: () => ({ - target: ts.ScriptTarget.Latest, - noEmit: true, - lib: [realLibPath.split(/[\\/]/).pop()!], - }), - getScriptFileNames: () => Object.keys(files), - getScriptVersion: () => '1', - getScriptSnapshot: n => { - if (n in files) return ts.ScriptSnapshot.fromString(files[n]); - if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); - return undefined; - }, - getCurrentDirectory: () => '/', - getDefaultLibFileName: () => realLibPath, - fileExists: n => n in files || n === realLibPath, - readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), - }; - return { typescript: ts, languageServiceHost: host, languageService: ts.createLanguageService(host) }; -} - -function emptyFileCache(mtime = 0): FileCache { - return { mtime, rules: {} }; -} - -// ── Test 1: syntactic rule writes cache entry ──────────────────────────── +// ── Test 1: lint writes the file's entry ───────────────────────────────── { - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const project = createProject({ 'a.ts': 'const x = 1;' }); const config: Config = { rules: { syntactic: ((rctx: RuleContext) => { rctx.report('hi', 0, 1); }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - check('syntactic rule cache entry written', !!cache.rules['syntactic']); - check('cache has 1 diagnostic', cache.rules['syntactic']?.diagnostics.length === 1); - check('hasFix false (no fix reported)', cache.rules['syntactic']?.hasFix === false); -} - -// ── Test 2: type-aware rule does not write cache entry ─────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { typed: ((rctx: RuleContext) => { - void rctx.program; + void rctx.checker; rctx.report('typed', 0, 1); }), }, }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - check('type-aware rule still produces diagnostics', result.length === 1); - check('type-aware rule NOT cached', !cache.rules['typed']); + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; + const target = project.target('a.ts'); + cacheFlow.lintWithCache(linter, target, files); + const entry = files[target.file.fileName]; + check('file entry written', !!entry); + check("entry holds every rule's diagnostics", entry?.diagnostics.length === 2); + check('hasFix false (no fix reported)', entry?.hasFix === false); } -// ── Test 3: cache hit skips rule, restores diagnostic ──────────────────── +// ── Test 2: cache hit skips every rule, replays diagnostics ────────────── { - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const project = createProject({ 'a.ts': 'const x = 1;' }); let runs = 0; - const config: Config = { - rules: { - syntactic: ((rctx: RuleContext) => { - runs++; - rctx.report('hi', 0, 1); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('first call ran rule', runs === 1); - - const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('second call did NOT re-run rule', runs === 1); - check('second call still produced 1 diagnostic', second.length === 1); - check( - 'restored diagnostic has live file ref', - !!second[0]?.file && typeof second[0]?.file.fileName === 'string', - ); -} - -// ── Test 4: mtime mismatch clears all rule entries ─────────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let runs = 0; - const config: Config = { - rules: { - r: ((rctx: RuleContext) => { - runs++; - rctx.report('x', 0, 1); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('first call ran', runs === 1); - - // Same mtime → cache hit - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('same mtime → still 1 run', runs === 1); - - // Different mtime → cache invalidated - cacheFlow.lintWithCache(linter, '/a.ts', cache, 2, program); - check('mtime change → re-run', runs === 2); - check('cache mtime updated', cache.mtime === 2); -} - -// ── Test 5: report-then-touch deletes any cache entry ──────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - 'report-then-touch': ((rctx: RuleContext) => { - rctx.report('first', 0, 1); - void rctx.program; // flips touchedProgram → mark type-aware - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - check('post-rule cleanup deleted entry', !cache.rules['report-then-touch']); -} - -// ── Test 6: sticky type-aware across files ─────────────────────────────── -{ - const ctx = makeContext({ - '/a.ts': 'const x = 1;', - '/b.ts': 'const y = 2;', - }); - const config: Config = { - rules: { - 'sometimes-typed': ((rctx: RuleContext) => { - if (rctx.file.fileName === '/a.ts') { - void rctx.program; - } - rctx.report('hi', 0, 1); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cacheA = emptyFileCache(1); - const cacheB = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - cacheFlow.lintWithCache(linter, '/a.ts', cacheA, 1, program); - cacheFlow.lintWithCache(linter, '/b.ts', cacheB, 1, program); - check('a.ts no cache entry (touched)', !cacheA.rules['sometimes-typed']); - check( - 'b.ts no cache entry (sticky classification)', - !cacheB.rules['sometimes-typed'], - ); -} - -// ── Test 7: stale cache entry for type-aware rule is ignored & cleaned ─── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let runs = 0; - const config: Config = { - rules: { - typed: ((rctx: RuleContext) => { - runs++; - void rctx.program; - rctx.report('typed', 0, 1); - }), - }, - }; - // Linter is seeded with `typed` as type-aware (e.g. from prior session - // via cache file's ruleModes). The fileCache also has a stale entry. - const linter = core.createLinter(ctx, '/', config, () => [], ['typed']); - const cache: FileCache = { - mtime: 1, - rules: { - typed: { - hasFix: false, - diagnostics: [{ - category: ts.DiagnosticCategory.Message, - code: 'typed' as any, - messageText: 'stale', - start: 0, - length: 1, - source: 'tsslint', - }], - }, - }, - }; - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - check('rule re-ran (stale cache ignored)', runs === 1); - check('result reflects fresh run', result.length === 1); - check('stale entry deleted', !cache.rules['typed']); -} - -// ── Test 8: hasFix flag derives from rule fix registration ─────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - fixable: ((rctx: RuleContext) => { - rctx.report('fix me', 0, 1).withFix('apply', () => []); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - check('hasFix true after rule registered fix', cache.rules['fixable']?.hasFix === true); -} - -// ── Test 9: multiple rules — only cached ones skipped, others run ──────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let aRuns = 0, bRuns = 0; const config: Config = { rules: { a: ((rctx: RuleContext) => { - aRuns++; + runs++; rctx.report('a', 0, 1); }), - b: ((rctx: RuleContext) => { - bRuns++; - rctx.report('b', 0, 1); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - // First call: both run - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('first call ran both', aRuns === 1 && bRuns === 1); - check('a cached', !!cache.rules['a']); - check('b cached', !!cache.rules['b']); - - // Second call: both cached - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('second call ran neither', aRuns === 1 && bRuns === 1); - check('result has both restored diagnostics', result.length === 2); -} - -// ── Test 10 (layer 2): typeAwareUnaffected=true caches type-aware rule ─── -// -// When the caller signals that the file's type-relevant inputs haven't -// moved (BuilderProgram check in production), type-aware rules become -// eligible for cache hits and writes — same path as syntactic rules. -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let runs = 0; - const config: Config = { - rules: { typed: ((rctx: RuleContext) => { runs++; void rctx.program; @@ -292,337 +63,157 @@ function emptyFileCache(mtime = 0): FileCache { }), }, }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); - check('first call ran (no cache yet)', runs === 1); - check( - 'type-aware entry written under unaffected signal', - !!cache.rules['typed'], - 'expected entry — typeAwareUnaffected=true makes type-aware caching legal', - ); + const cold = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); + check('first call ran both rules', runs === 2); + + const warm = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); + check('second call ran no rule', runs === 2); + check('second call replayed both diagnostics', warm.length === 2); check( - 'cached diagnostic count', - cache.rules['typed']?.diagnostics.length === 1, + 'replayed diagnostics match the cold run', + JSON.stringify(warm) === JSON.stringify(cold), ); -} - -// ── Test 11 (layer 2): cache hit on second call with same signal ───────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let runs = 0; - const config: Config = { - rules: { - typed: ((rctx: RuleContext) => { - runs++; - void rctx.program; - rctx.report('typed', 0, 1); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); - const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { - incremental: true, - typeAwareUnaffected: true, - }); - check('second call did NOT re-run type-aware rule', runs === 1); - check('second call still produced 1 diagnostic', second.length === 1); check( - 'restored diagnostic has live file ref (layer 2)', - !!second[0]?.file && typeof second[0]?.file.fileName === 'string', + 'replayed diagnostics are copies', + warm[0] !== files[project.target('a.ts').file.fileName].diagnostics[0], ); } -// ── Test 12 (layer 2): mode B → mode A re-runs and clears entry ────────── +// ── Test 3: a deleted entry re-lints ───────────────────────────────────── // -// Cache entry was written under typeAwareUnaffected=true. Next call -// drops the signal (BuilderProgram now considers the file affected). -// The type-aware rule must re-run AND its old entry deleted. +// Invalidation deletes entries before linting starts; whatever is left +// is replayed as is. { - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const project = createProject({ 'a.ts': 'const x = 1;' }); let runs = 0; const config: Config = { rules: { - typed: ((rctx: RuleContext) => { + r: ((rctx: RuleContext) => { runs++; - void rctx.program; - rctx.report('typed', 0, 1); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - // Mode B: write the entry. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); - check('layer-2 first run cached the entry', !!cache.rules['typed']); - - // Mode A (default): file is affected. Re-run, drop entry. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('mode-A re-run executed rule', runs === 2); - check('mode-A re-run dropped the entry', !cache.rules['typed']); -} - -// ── Test 13 (layer 2): mode A (no incremental) never caches type-aware ── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - typed: ((rctx: RuleContext) => { - void rctx.program; - rctx.report('typed', 0, 1); + rctx.report('x', 0, 1); }), }, }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - // No options arg → mode A. Type-aware rules never cached. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - check('default (no options) does NOT cache type-aware', !cache.rules['typed']); - - // Explicit incremental=false also mode A. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: false }); - check('explicit incremental=false does NOT cache type-aware', !cache.rules['typed']); + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; + const target = project.target('a.ts'); + + cacheFlow.lintWithCache(linter, target, files); + delete files[target.file.fileName]; + cacheFlow.lintWithCache(linter, target, files); + check('deleted entry → re-run', runs === 2); + check('entry written again', !!files[target.file.fileName]); } -// ── Test 14 (layer 2): incremental writes type-aware even when affected ─ +// ── Test 4: hasFix, and `fix` re-lints a file with fixes ───────────────── // -// First-time-ever (cold session) under --incremental: there's no prior -// entry to hit, but we must still WRITE one so the next session can -// hit. The split between "trust cache" (typeAwareUnaffected) and "write -// cache" (incremental) is the gate. +// Fixes are closures that don't survive the cache. { - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const project = createProject({ 'a.ts': 'const x = 1;', 'b.ts': 'const y = 2;' }); let runs = 0; const config: Config = { rules: { - typed: ((rctx: RuleContext) => { + fixable: ((rctx: RuleContext) => { runs++; - void rctx.program; - rctx.report('typed', 0, 1); + if (rctx.file.fileName.endsWith('/a.ts')) { + rctx.report('fix me', 0, 1).withFix('apply', () => []); + } }), }, }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; - - // Cold session under --incremental: no prev state, file is "affected" - // (unaffected=false). Must run AND write entry. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { - incremental: true, - typeAwareUnaffected: false, - }); - check('cold session ran the rule', runs === 1); - check( - 'cold session under --incremental wrote type-aware entry', - !!cache.rules['typed'], - 'entry needed for next session to cache-hit', - ); + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; + const a = project.target('a.ts'); + const b = project.target('b.ts'); + cacheFlow.lintWithCache(linter, a, files); + cacheFlow.lintWithCache(linter, b, files); + check('hasFix true after rule registered fix', files[a.file.fileName]?.hasFix === true); + check('hasFix false without fixes', files[b.file.fileName]?.hasFix === false); + + cacheFlow.lintWithCache(linter, a, files, { fix: true }); + check('fix re-lints a file with fixes', runs === 3); + cacheFlow.lintWithCache(linter, b, files, { fix: true }); + check('fix replays a file without fixes', runs === 3); } -// ── Test 15: withoutCache() — diagnostic returned but not persisted ───── +// ── Test 5: files the rules read beyond their declarations ────────────── // -// `Reporter.withoutCache()` is the rule's contract: "this finding's -// correctness depends on inputs neither layer tracks; please don't -// replay it from the cache." The current run still surfaces the -// diagnostic; it just isn't written to disk, so the next warm hit on -// the same file won't see it. +// Reading a declaration node, a source file, or querying the checker by +// file makes the linted file depend on the other file's text. Types alone +// don't; declaration signatures cover them. { - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - env_dependent: ((rctx: RuleContext) => { - rctx.report('depends on env', 0, 1).withoutCache(); - rctx.report('plain', 0, 1); - }), - }, + const project = createProject({ + 'lib.ts': 'export function load() {\n\treturn 1;\n}\n', + 'main.ts': `import { load } from './lib';\nload();\n`, + }); + const main = project.target('main.ts'); + const libName = project.target('lib.ts').file.fileName; + const getCall = (rctx: RuleContext) => { + const statement = rctx.file.statements[1]; + if (!rctx.typescript.isExpressionStatement(statement) || !rctx.typescript.isCallExpression(statement.expression)) { + throw new Error('unexpected fixture'); + } + return statement.expression; }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const diags = cacheFlow.lintWithCache( - linter, - '/a.ts', - cache, - 1, - ctx.languageService.getProgram()!, - ); - check('current run returns both diagnostics', diags.length === 2); - check( - 'cache entry exists for the rule', - !!cache.rules['env_dependent'], - ); - check( - 'only the non-marked diagnostic is persisted', - cache.rules['env_dependent']?.diagnostics.length === 1, - `expected 1 cached diagnostic, got ${cache.rules['env_dependent']?.diagnostics.length}`, - ); - check( - 'persisted diagnostic is the plain one', - cache.rules['env_dependent']?.diagnostics[0]?.messageText === 'plain', - ); -} - -// ── Test 16: withoutCache() — second run cache-hits with the survivor ─── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - env_dependent: ((rctx: RuleContext) => { - rctx.report('depends on env', 0, 1).withoutCache(); - rctx.report('plain', 0, 1); - }), - }, + const touchedBy = (rule: (rctx: RuleContext) => void) => { + const linter = core.createLinter(project.ctx, project.rootDir, { rules: { rule } }); + const files: Record = {}; + cacheFlow.lintWithCache(linter, main, files, { + hashFiles: fileNames => Object.fromEntries(fileNames.map(fileName => [fileName, 'hash'])), + }); + return Object.keys(files[main.file.fileName]?.touched ?? {}).join(','); }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - - // Second run with the same mtime — rule cache-hits, only the - // persisted diagnostic comes back. - const linter2 = core.createLinter(ctx, '/', config, () => []); - const diags = cacheFlow.lintWithCache( - linter2, - '/a.ts', - cache, - 1, - ctx.languageService.getProgram()!, - ); - check('warm cache hit returns 1 diagnostic', diags.length === 1); - check('warm replay drops the marked one', diags[0]?.messageText === 'plain'); -} -// ── Test 17 (regression): mixed-mode rule, early-return file replays sound -// -// A rule that file-shape-filters before reading `program` will, for the -// early-returning file, finish without touching the probe. Verify the -// resulting cache entry replays correctly across sessions even after the -// rule is globally classified type-aware (because some OTHER file did -// touch program). The early-return path's output is a deterministic -// function of file text alone — replaying it on a warm hit should match -// what re-running the rule would produce. -{ - const ctx = makeContext({ - '/skip.ts': 'const x = 1;', - '/check.ts': 'const y = 2;', + const types = touchedBy(rctx => { + rctx.report(rctx.checker.typeToString(rctx.checker.getTypeAtLocation(getCall(rctx))), 0, 1); }); - const config: Config = { - rules: { - 'mixed-mode': ((rctx: RuleContext) => { - if (rctx.file.fileName === '/skip.ts') return; - void rctx.program; - rctx.report('typed', 0, 1); - }), - }, - }; + check('types alone touch no file', types === '', types); - // ── Session 1: cold, both files lint — process the early-return file - // FIRST so the rule isn't yet type-aware when its entry gets written. - const linter1 = core.createLinter(ctx, '/', config, () => []); - const program1 = ctx.languageService.getProgram()!; - const cacheSkip: FileCache = emptyFileCache(1); - const cacheCheck: FileCache = emptyFileCache(1); - cacheFlow.lintWithCache(linter1, '/skip.ts', cacheSkip, 1, program1); - cacheFlow.lintWithCache(linter1, '/check.ts', cacheCheck, 1, program1); + const body = touchedBy(rctx => { + const symbol = rctx.checker.getAliasedSymbol(rctx.checker.getSymbolAtLocation(getCall(rctx).expression)!); + const declaration = symbol.valueDeclaration!.resolve()!; + rctx.report(declaration.getText(), 0, 1); + }); + check('a resolved declaration touches its file', body === libName, body); - check( - 'session 1: rule classified type-aware after both files', - linter1.getTypeAwareRules().has('mixed-mode'), - ); - check( - "session 1: early-return file got an entry (rule wasn't yet type-aware at write time)", - !!cacheSkip.rules['mixed-mode'], - ); - check( - "session 1: early-return file's entry has 0 diagnostics (rule reported nothing)", - cacheSkip.rules['mixed-mode']?.diagnostics.length === 0, - ); + const sourceFile = touchedBy(rctx => { + rctx.program.getSourceFile(libName); + }); + check('program.getSourceFile touches the file', sourceFile === libName, sourceFile); - // ── Session 2: rule pre-classified type-aware (from session 1). - // Both files unchanged. typeAwareUnaffected=true → both should - // cache-hit and replay cleanly. - const linter2 = core.createLinter(ctx, '/', config, () => [], ['mixed-mode']); - const program2 = ctx.languageService.getProgram()!; - let earlyReturnRanInSession2 = false; - const config2: Config = { - rules: { - 'mixed-mode': ((rctx: RuleContext) => { - earlyReturnRanInSession2 = true; - if (rctx.file.fileName === '/skip.ts') return; - void rctx.program; - rctx.report('typed', 0, 1); - }), - }, - }; - const linterMonitored = core.createLinter(ctx, '/', config2, () => [], ['mixed-mode']); - const session2Skip = cacheFlow.lintWithCache( - linterMonitored, - '/skip.ts', - cacheSkip, - 1, - program2, - { incremental: true, typeAwareUnaffected: true }, - ); - check( - 'session 2: warm hit on early-return file replays empty diagnostics', - session2Skip.length === 0, - ); - check( - 'session 2: rule body did NOT execute on early-return file (cache skipped it)', - !earlyReturnRanInSession2, - "cache-hit means we skip the rule entirely — body shouldn't run", - ); - void linter2; // type-only ref; linterMonitored is the one we observe -} + const batched = touchedBy(rctx => { + const api = (rctx.program as any).project.api; + api.batch(rctx.program.getSourceFile.gen(libName)); + }); + check('a batched getSourceFile touches the file', batched === libName, batched); -// ── Test 18: NO_CACHE marker doesn't leak through serialisation ───────── -// -// Symbol-keyed property must stay invisible to JSON.stringify and to -// `{...spread}` so the on-disk cache stays clean. -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - r: ((rctx: RuleContext) => { - rctx.report('marked', 0, 1).withoutCache(); - }), - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); - // rule entry exists but with 0 diagnostics — no smuggled keys. - const json = JSON.stringify(cache); - check('NO_CACHE marker not visible in serialised cache', !json.includes('no-cache')); + const position = touchedBy(rctx => { + rctx.checker.getSymbolAtPosition(libName, 17); + }); + check('a checker query by file touches the file', position === libName, position); + + const own = touchedBy(rctx => { + rctx.program.getSourceFile(rctx.file.fileName); + }); + check('the linted file is not recorded', own === '', own); } -// ── Test 19: warm cache must not break resolveDiagnostics view ─────────── +// ── Test 6: warm replay keeps the ignore plugin's result ──────────────── // // Scenario: a rule fires on line 2, an `// eslint-disable-next-line foo` // comment on line 1 suppresses it, ignore plugin (`reportsUnusedComments // = true`) marks the comment used. Cold pass produces 0 user-visible -// errors. Warm pass: the rule is cached → skipped → its diagnostic -// would be missing from the array `resolveDiagnostics` sees, so the -// ignore plugin can no longer match the comment to a diagnostic and -// erroneously reports it as unused. -// -// Cache flow MUST seed `prevDiagnostics` with the cached entries before -// `resolveDiagnostics` runs, so the plugin's view matches the cold pass. +// errors. The entry holds the result after plugins ran, so the warm pass +// must not turn the comment into an unused-comment false positive. { const ignorePlugin = require('@tsslint/config/lib/plugins/ignore.js') as { create: (cmd: string | [string, string], reportsUnused: boolean) => any; }; const code = '// eslint-disable-next-line foo\nconst x = 1;\n'; - const ctx = makeContext({ '/a.ts': code }); + const project = createProject({ 'a.ts': code }); const config: Config = { rules: { foo: ((rctx: RuleContext) => { @@ -633,39 +224,28 @@ function emptyFileCache(mtime = 0): FileCache { }, plugins: [ignorePlugin.create('eslint-disable-next-line', true)], }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; - const cold = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + const cold = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); check( 'cold pass: rule diag suppressed by disable comment, no user-visible diag', cold.length === 0, - `cold returned ${cold.length} diags: ${cold.map(d => d.code).join(',')}`, - ); - check('cold pass: rule entry written to cache', !!cache.rules['foo']); - check( - 'cold pass: cache stores the suppressed diagnostic so warm can re-evaluate', - cache.rules['foo']?.diagnostics.length === 1, + `cold returned ${cold.length} diags: ${cold.map(d => d.ruleId).join(',')}`, ); + check('cold pass: entry written to cache', files[project.target('a.ts').file.fileName]?.diagnostics.length === 0); - const warm = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + const warm = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); check( 'warm pass: same comment suppresses the same (now-cached) diag — no unused-comment FP', warm.length === 0, - `warm returned ${warm.length} diags: ${warm.map(d => d.code).join(',')}`, + `warm returned ${warm.length} diags: ${warm.map(d => d.ruleId).join(',')}`, ); } -// ── Test 20: prevDiagnostics + fresh must not duplicate ────────────────── -// -// `skipRules` and the rules that actually run are disjoint by construction -// (cache-flow only adds a rule to skipRules if it has a cached entry, and -// lint skips those — so they can't also produce fresh output). Verify the -// invariant: across cold + warm passes for a non-suppressed diagnostic, -// the user sees exactly one report each pass — never two. +// ── Test 7: cold and warm passes report once each ─────────────────────── { - const ctx = makeContext({ '/a.ts': 'const x = 1;\n' }); + const project = createProject({ 'a.ts': 'const x = 1;\n' }); let runs = 0; const config: Config = { rules: { @@ -675,24 +255,23 @@ function emptyFileCache(mtime = 0): FileCache { }), }, }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; - const cold = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + const cold = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); check('cold: rule ran once', runs === 1); check('cold: 1 diag returned', cold.length === 1); - const warm = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + const warm = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); check('warm: rule did NOT re-run (cached)', runs === 1); check('warm: 1 diag returned (no duplication)', warm.length === 1); check( 'warm diag has same payload as cold', - warm[0].code === cold[0].code && warm[0].start === cold[0].start, + warm[0].ruleId === cold[0].ruleId && warm[0].pos === cold[0].pos, ); } -// ── Test 21: multiple rules + ignore plugin, no duplication on warm ────── +// ── Test 8: multiple rules + ignore plugin, no duplication on warm ───── // // Two rules, only one suppressed by a disable comment. Warm pass must // produce the same final set as cold — no double-report on the @@ -702,7 +281,7 @@ function emptyFileCache(mtime = 0): FileCache { create: (cmd: string | [string, string], reportsUnused: boolean) => any; }; const code = '// eslint-disable-next-line foo\nconst x = 1;\n'; - const ctx = makeContext({ '/a.ts': code }); + const project = createProject({ 'a.ts': code }); const config: Config = { rules: { foo: ((rctx: RuleContext) => { @@ -716,28 +295,27 @@ function emptyFileCache(mtime = 0): FileCache { }, plugins: [ignorePlugin.create('eslint-disable-next-line', true)], }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; - const cold = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - const coldCodes = cold.map(d => d.code).sort(); + const cold = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); + const coldCodes = cold.map(d => d.ruleId).sort(); check( 'cold: only `bar` survives (`foo` suppressed by comment for `foo`)', - cold.length === 1 && String(cold[0].code) === 'bar', + cold.length === 1 && cold[0].ruleId === 'bar', `cold codes: ${coldCodes.join(',')}`, ); - const warm = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); - const warmCodes = warm.map(d => d.code).sort(); + const warm = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); + const warmCodes = warm.map(d => d.ruleId).sort(); check( 'warm: same single `bar` — no duplicate, no resurrected `foo`, no FP unused-comment', - warm.length === 1 && String(warm[0].code) === 'bar', + warm.length === 1 && warm[0].ruleId === 'bar', `warm codes: ${warmCodes.join(',')}`, ); } -// ── Test 22: ignore plugin's regex must capture scoped rule IDs ────────── +// ── Test 9: ignore plugin's regex must capture scoped rule IDs ───────── // // `@scope/rule` and `plugin/rule` IDs (the dominant naming in the // ecosystem) must be captured into `groups.ruleId` so each block-disable @@ -756,7 +334,7 @@ function emptyFileCache(mtime = 0): FileCache { create: (cmd: string | [string, string], reportsUnused: boolean) => any; }; const code = '/* eslint-disable @typescript-eslint/r1 */\n/* eslint-disable @typescript-eslint/r2 */\nconst x = 1;\n'; - const ctx = makeContext({ '/a.ts': code }); + const project = createProject({ 'a.ts': code }); const config: Config = { rules: { '@typescript-eslint/r1': ((rctx: RuleContext) => { @@ -770,19 +348,18 @@ function emptyFileCache(mtime = 0): FileCache { }, plugins: [ignorePlugin.create(['eslint-disable', 'eslint-enable'], true)], }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + const result = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); check( 'scoped block-disables: each independently suppresses its targeted rule (no unused FP on second comment)', result.length === 0, - `expected 0 diags; got ${result.length}: ${result.map(d => `${d.code}@${d.start}`).join(',')}`, + `expected 0 diags; got ${result.length}: ${result.map(d => `${d.ruleId}@${d.pos}`).join(',')}`, ); } -// ── Test 23: ignore plugin comment scanner — non-comment contexts ───────── +// ── Test 10: ignore plugin comment scanner — non-comment contexts ──────── // // The plugin's inlined comment walker relies on the AST-realized token // boundaries (via `node.getChildren()` + `forEachLeading/TrailingCommentRange`) @@ -819,23 +396,23 @@ function emptyFileCache(mtime = 0): FileCache { }, ]; for (const { label, code } of cases) { - const ctx = makeContext({ '/a.ts': code }); + const project = createProject({ 'a.ts': code }); const config: Config = { rules: {}, plugins: [ignorePlugin.create('eslint-disable-next-line', true)], }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; + const result = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); check( `scanner: no phantom directive in ${label}`, result.length === 0, - `expected 0 diags; got ${result.length}: ${result.map(d => d.code).join(',')}`, + `expected 0 diags; got ${result.length}: ${result.map(d => d.ruleId).join(',')}`, ); } } -// ── Test 24: ignore plugin must collect ALL leading comments per token ─── +// ── Test 11: ignore plugin must collect ALL leading comments per token ── // // `ts.forEachLeading/TrailingCommentRange` short-circuits when the // callback returns a truthy value (matches the documented `forEachX` @@ -854,14 +431,14 @@ function emptyFileCache(mtime = 0): FileCache { for (let i = 0; i < 20; i++) lines.push(`// eslint-disable-next-line r${i}`); lines.push('const x = 1;'); const code = lines.join('\n') + '\n'; - const ctx = makeContext({ '/a.ts': code }); + const project = createProject({ 'a.ts': code }); const config: Config = { rules: {}, plugins: [ignorePlugin.create('eslint-disable-next-line', true)], }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = emptyFileCache(1); - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + const linter = core.createLinter(project.ctx, project.rootDir, config); + const files: Record = {}; + const result = cacheFlow.lintWithCache(linter, project.target('a.ts'), files); // Each disable targets a non-existent rule → all 20 are unused → 20 diagnostics. check( 'comment-trivia callback: all N stacked comments processed (no truthy-return short-circuit)', diff --git a/packages/cli/test/cache.test.ts b/packages/cli/test/cache.test.ts index ca0e8d29..71d07f47 100644 --- a/packages/cli/test/cache.test.ts +++ b/packages/cli/test/cache.test.ts @@ -11,6 +11,7 @@ import path = require('path'); import fs = require('fs'); import os = require('os'); +import type { CacheData } from '../lib/cache.js'; const cache = require('../lib/cache.js') as typeof import('../lib/cache.js'); @@ -34,214 +35,150 @@ function tmpFile(name: string, content = ''): string { return p; } -// Override the cache root path. The module hardcodes os.tmpdir() — -// can't redirect cleanly, so just accept that test artefacts land -// alongside real cache files. Use a unique tsslint version segment via -// monkey-patching require.cache to make tests isolated. -// -// Simpler: just use unique configFile contents to force unique hashes. - +// The module hardcodes os.tmpdir(), so test artefacts land alongside real +// cache files. Unique config paths force unique hashes. function configWithMarker(marker: string): string { return tmpFile(`config-${marker}.ts`, `// ${marker}`); } +function withMarker(marker: string, data: Partial = {}): CacheData { + return { ...cache.emptyCache(), globalKey: marker, ...data }; +} + +// Finds the cache file saved with `withMarker(marker)`. +function findCacheFile(marker: string): string | undefined { + const stack = [path.join(os.tmpdir(), 'tsslint-cache')]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } + catch { + continue; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + stack.push(full); + } + else if (e.isFile() && full.endsWith('.cache.json')) { + try { + if (JSON.parse(fs.readFileSync(full, 'utf8')).globalKey === marker) { + return full; + } + } + catch { /* ignore */ } + } + } + } +} + // ── Test 1: empty load when file doesn't exist ─────────────────────────── { const tsconfig = tmpFile('tsconfig1.json', '{}'); const config = configWithMarker('t1-' + Date.now()); - const data = cache.loadCache(tsconfig, config, [], '6.0.0'); + const data = cache.loadCache(tsconfig, config, '6.0.0'); check('empty cache when file missing', data.files !== undefined && Object.keys(data.files).length === 0); - check('ruleModes empty', Object.keys(data.ruleModes).length === 0); + check('graph empty', Object.keys(data.graph).length === 0); + check('globalKey absent', data.globalKey === undefined); check('version present', typeof data.version === 'string'); } // ── Test 2: round-trip save → load ─────────────────────────────────────── { const tsconfig = tmpFile('tsconfig2.json', '{}'); - const config = configWithMarker('t2-' + Date.now()); - const original: import('../lib/cache.js').CacheData = { - version: 'v2', - ruleModes: { 'no-undef': 'type-aware' }, + const marker = 't2-' + Date.now(); + const config = configWithMarker(marker); + const original = withMarker(marker, { + graph: { + '/abs/foo.ts': { mtime: 1234567890, size: 10, signature: 'sig', deps: ['/abs/bar.ts'], global: false }, + }, files: { - '/abs/foo.ts': { - mtime: 1234567890, - rules: { - 'semi': { hasFix: false, diagnostics: [] }, - }, - }, + '/abs/foo.ts': { hasFix: true, diagnostics: [], touched: { '/abs/baz.ts': 'hash' } }, }, - }; - cache.saveCache(tsconfig, config, [], '6.0.0', original); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('round-trip: ruleModes preserved', loaded.ruleModes['no-undef'] === 'type-aware'); - check('round-trip: file mtime preserved', loaded.files['/abs/foo.ts']?.mtime === 1234567890); - check('round-trip: rule entry preserved', !!loaded.files['/abs/foo.ts']?.rules['semi']); + }); + cache.saveCache(tsconfig, config, '6.0.0', original); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check('round-trip: globalKey preserved', loaded.globalKey === marker); + check('round-trip: graph mtime preserved', loaded.graph['/abs/foo.ts']?.mtime === 1234567890); + check('round-trip: signature preserved', loaded.graph['/abs/foo.ts']?.signature === 'sig'); + check('round-trip: deps preserved', loaded.graph['/abs/foo.ts']?.deps[0] === '/abs/bar.ts'); + check('round-trip: file entry preserved', loaded.files['/abs/foo.ts']?.hasFix === true); + check('round-trip: touched files preserved', loaded.files['/abs/foo.ts']?.touched['/abs/baz.ts'] === 'hash'); } // ── Test 3: corrupted JSON returns empty cache ─────────────────────────── { const tsconfig = tmpFile('tsconfig3.json', '{}'); - const config = configWithMarker('t3-' + Date.now()); - cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); - - // Find the cache file and corrupt it. - const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); - const findCacheFile = (): string | null => { - const stack = [cacheRoot]; - while (stack.length) { - const dir = stack.pop()!; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } - catch { - continue; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) stack.push(full); - else if (e.isFile() && full.endsWith('.cache.json')) { - try { - const content = fs.readFileSync(full, 'utf8'); - const parsed = JSON.parse(content); - // Find the one we just wrote (heuristic: empty data). - if ( - parsed.version === 'v2' - && Object.keys(parsed.ruleModes).length === 0 - && Object.keys(parsed.files).length === 0 - ) { - return full; - } - } - catch { /* ignore */ } - } - } - } - return null; - }; - const cacheFile = findCacheFile(); + const marker = 't3-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); + const cacheFile = findCacheFile(marker); check('found newly-written cache file', !!cacheFile); if (cacheFile) { fs.writeFileSync(cacheFile, '{not valid json'); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('corrupted JSON → empty cache', Object.keys(loaded.files).length === 0); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check('corrupted JSON → empty cache', loaded.globalKey === undefined); } } // ── Test 4: version mismatch returns empty cache ───────────────────────── { const tsconfig = tmpFile('tsconfig4.json', '{}'); - const config = configWithMarker('t4-' + Date.now()); - const stale = { - version: 'v1', // wrong - ruleModes: { 'old-rule': 'type-aware' }, - files: { '/x': { mtime: 1, rules: {} } }, - }; - // Manually write a stale-version file - cache.saveCache(tsconfig, config, [], '6.0.0', stale as any); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('version mismatch → empty', Object.keys(loaded.ruleModes).length === 0); + const marker = 't4-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker, { version: 'v4' })); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check('version mismatch → empty', loaded.globalKey === undefined); } // ── Test 5: shape mismatch returns empty cache ─────────────────────────── { const tsconfig = tmpFile('tsconfig5.json', '{}'); - const config = configWithMarker('t5-' + Date.now()); - cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); - // Corrupt to a valid-JSON-but-wrong-shape value. Find the file we - // just wrote (heuristic: empty cache contents). - const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); - const all: string[] = []; - const stack = [cacheRoot]; - while (stack.length) { - const dir = stack.pop()!; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } - catch { - continue; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) stack.push(full); - else if (e.isFile() && full.endsWith('.cache.json')) all.push(full); - } - } - // Look for one whose content is empty cache (the one we just wrote). - let target: string | null = null; - for (const f of all) { - try { - const o = JSON.parse(fs.readFileSync(f, 'utf8')); - if ( - o.version === 'v2' && Object.keys(o.files).length === 0 - && Object.keys(o.ruleModes).length === 0 - ) { - // Could be from a previous test; pick first. - target = f; - break; - } - } - catch { /* ignore */ } - } + const marker = 't5-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); + const target = findCacheFile(marker); + check('test 5 cache file found', !!target); if (target) { fs.writeFileSync(target, '"just a string"'); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('shape mismatch → empty', Object.keys(loaded.files).length === 0); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check('shape mismatch → empty', loaded.globalKey === undefined); } } // ── Test 6: different ts version → different cache file ────────────────── { const tsconfig = tmpFile('tsconfig6.json', '{}'); - const config = configWithMarker('t6-' + Date.now()); - const data: import('../lib/cache.js').CacheData = { - version: 'v2', - ruleModes: { 'rule6a': 'type-aware' }, - files: {}, - }; - cache.saveCache(tsconfig, config, [], '6.0.0', data); - const loadedSameVersion = cache.loadCache(tsconfig, config, [], '6.0.0'); - const loadedDifferentVersion = cache.loadCache(tsconfig, config, [], '7.0.0'); + const marker = 't6-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); + const loadedSameVersion = cache.loadCache(tsconfig, config, '6.0.0'); + const loadedDifferentVersion = cache.loadCache(tsconfig, config, '7.0.0'); - check( - 'same ts version finds the cache', - loadedSameVersion.ruleModes['rule6a'] === 'type-aware', - ); - check( - 'different ts version → different file → empty', - !loadedDifferentVersion.ruleModes['rule6a'], - ); + check('same ts version finds the cache', loadedSameVersion.globalKey === marker); + check('different ts version → different file → empty', loadedDifferentVersion.globalKey === undefined); } -// ── Test 7: editing config file changes cache file path ────────────────── +// ── Test 7: editing the config file keeps the cache file ───────────────── // -// configStat.mtimeMs / size is in the cache key. Mutating the config -// content + bumping mtime should land at a different cache file. +// The config and the modules it loads are part of `globalKey`, which +// re-lints every file when it changes; the path only depends on which +// config and tsconfig the cache belongs to. { const tsconfig = tmpFile('tsconfig7.json', '{}'); - const config = configWithMarker('t7-' + Date.now()); - - const data: import('../lib/cache.js').CacheData = { - version: 'v2', - ruleModes: { 'rule7': 'type-aware' }, - files: {}, - }; - cache.saveCache(tsconfig, config, [], '6.0.0', data); + const marker = 't7-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); - // Bump config size + mtime. fs.writeFileSync(config, '// changed content'); - // Bump mtime explicitly to make sure the test isn't flaky on - // filesystems with low mtime resolution. const newTime = new Date(Date.now() + 60_000); fs.utimesSync(config, newTime, newTime); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check( - 'config edit invalidates cache (different path key)', - !loaded.ruleModes['rule7'], - 'expected fresh cache after config mtime+size change', - ); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check('config edit keeps the cache path', loaded.globalKey === marker); + check('different tsconfig → different file', cache.loadCache(config, tsconfig, '6.0.0').globalKey === undefined); } // ── Test 8: atomic write uses .tmp file ────────────────────────────────── @@ -251,143 +188,69 @@ function configWithMarker(marker: string): string { // after a successful save. { const tsconfig = tmpFile('tsconfig8.json', '{}'); - const config = configWithMarker('t8-' + Date.now()); - cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); - - const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); - let tmpLeaked = false; - const stack = [cacheRoot]; - while (stack.length) { - const dir = stack.pop()!; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } - catch { - continue; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) stack.push(full); - else if (e.isFile() && full.endsWith('.tmp')) tmpLeaked = true; - } - } - check('no .tmp leaked after successful save', !tmpLeaked); + const marker = 't8-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); + const target = findCacheFile(marker); + check('test 8 cache file found', !!target); + check('no .tmp leaked after successful save', !!target && !fs.existsSync(target + '.tmp')); } -// ── Test 9: inner shape mismatch (corrupt file entry) → empty cache ───── +// ── Test 9: inner shape mismatch → empty cache ────────────────────────── // -// isCacheData now validates `files[*]` entries deeply: a bad inner shape -// (mtime is a string, rules is null) must reject the whole cache instead -// of letting the load succeed and crashing later in the lint loop. +// isCacheData validates graph and file entries deeply: a bad inner shape +// must reject the whole cache instead of letting the load succeed and +// crashing later in the lint loop. { const tsconfig = tmpFile('tsconfig9.json', '{}'); - const config = configWithMarker('t9-' + Date.now()); - cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); - const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); - let target: string | null = null; - const stack = [cacheRoot]; - while (stack.length) { - const dir = stack.pop()!; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } - catch { - continue; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) stack.push(full); - else if (e.isFile() && full.endsWith('.cache.json')) { - try { - const o = JSON.parse(fs.readFileSync(full, 'utf8')); - if (o.version === 'v2' && Object.keys(o.files).length === 0) { - target = full; - break; - } - } - catch { /* ignore */ } - } - } - if (target) break; - } + const marker = 't9-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); + const target = findCacheFile(marker); + check('test 9 cache file found', !!target); if (target) { - // mtime is a string, not a number — must reject. - const corrupt = { - version: 'v2', - ruleModes: {}, - files: { '/x': { mtime: 'not-a-number', rules: {} } }, - }; - fs.writeFileSync(target, JSON.stringify(corrupt)); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('inner mtime mismatch → empty cache', Object.keys(loaded.files).length === 0); - - // rules is null — must also reject. - const corrupt2 = { - version: 'v2', - ruleModes: {}, - files: { '/x': { mtime: 1, rules: null } }, - }; - fs.writeFileSync(target, JSON.stringify(corrupt2)); - const loaded2 = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('inner rules null → empty cache', Object.keys(loaded2.files).length === 0); + const base = withMarker(marker); + const cases: [string, unknown][] = [ + ['graph mtime not a number', { + ...base, + graph: { '/x': { mtime: 'nope', size: 1, deps: [], global: false } }, + }], + ['graph deps not an array', { ...base, graph: { '/x': { mtime: 1, size: 1, deps: null, global: false } } }], + ['file diagnostics not an array', { + ...base, + files: { '/x': { hasFix: false, diagnostics: null, touched: {} } }, + }], + ['file hasFix missing', { ...base, files: { '/x': { diagnostics: [], touched: {} } } }], + ['file touched missing', { ...base, files: { '/x': { hasFix: false, diagnostics: [] } } }], + ]; + for (const [label, corrupt] of cases) { + fs.writeFileSync(target, JSON.stringify(corrupt)); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check(`${label} → empty cache`, loaded.globalKey === undefined); + } } } -// ── Test 10: incrementalState shape gate ──────────────────────────────── +// ── Test 10: globalKey shape gate ─────────────────────────────────────── // -// `incrementalState` is optional, but if present must be a -// `{version, tsBuildInfoText}` object. A stray `incrementalState: 42` -// would otherwise sneak past and crash later when reconstructOldBuilder -// reads `.tsBuildInfoText`. +// `globalKey` is optional, but if present must be a string. A stray +// `globalKey: 42` must not be compared against a real key as if it were +// one. { const tsconfig = tmpFile('tsconfig10.json', '{}'); - const config = configWithMarker('t10-' + Date.now()); - cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); - const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); - let target: string | null = null; - const stack = [cacheRoot]; - while (stack.length) { - const dir = stack.pop()!; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } - catch { - continue; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) stack.push(full); - else if (e.isFile() && full.endsWith('.cache.json')) { - try { - const o = JSON.parse(fs.readFileSync(full, 'utf8')); - if (o.version === 'v2' && Object.keys(o.files).length === 0) { - target = full; - break; - } - } - catch { /* ignore */ } - } - } - if (target) break; - } + const marker = 't10-' + Date.now(); + const config = configWithMarker(marker); + cache.saveCache(tsconfig, config, '6.0.0', withMarker(marker)); + const target = findCacheFile(marker); + check('test 10 cache file found', !!target); if (target) { - const corrupt = { version: 'v2', ruleModes: {}, files: {}, incrementalState: 42 }; - fs.writeFileSync(target, JSON.stringify(corrupt)); - const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('incrementalState wrong type → empty cache', loaded.incrementalState === undefined); + fs.writeFileSync(target, JSON.stringify({ ...cache.emptyCache(), globalKey: 42 })); + const loaded = cache.loadCache(tsconfig, config, '6.0.0'); + check('globalKey wrong type → empty cache', loaded.globalKey === undefined); - const corrupt2 = { - version: 'v2', - ruleModes: {}, - files: {}, - incrementalState: { version: 'v3' /* tsBuildInfoText missing */ }, - }; - fs.writeFileSync(target, JSON.stringify(corrupt2)); - const loaded2 = cache.loadCache(tsconfig, config, [], '6.0.0'); - check('incrementalState missing field → empty cache', loaded2.incrementalState === undefined); + fs.writeFileSync(target, JSON.stringify({ ...cache.emptyCache(), globalKey: 'abc' })); + const loaded2 = cache.loadCache(tsconfig, config, '6.0.0'); + check('globalKey string round-trips', loaded2.globalKey === 'abc'); } } diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts deleted file mode 100644 index b8f55f10..00000000 --- a/packages/cli/test/incremental-state.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -// Round-trip tests for the layer 2 cross-session state. Verifies that -// capturing a BP's state via TS internal `emitBuildInfo` produces a -// text the next session can feed back through -// `createBuilderProgramUsingIncrementalBuildInfo` to get an oldBP that -// correctly diffs the current program. -// -// Run via: -// node packages/cli/test/incremental-state.test.js - -import * as ts from 'typescript'; - -const inc = require('../lib/incremental-state.js') as typeof import('../lib/incremental-state.js'); - -const failures: string[] = []; -function check(name: string, cond: boolean, detail?: string) { - if (cond) { - process.stdout.write('.'); - } - else { - failures.push(name + (detail ? ' — ' + detail : '')); - process.stdout.write('F'); - } -} - -const realLib = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); -const libContent = ts.sys.readFile(realLib) ?? ''; -const libName = realLib.split(/[\\/]/).pop()!; - -function buildProgram(files: Record): ts.Program { - const host: ts.CompilerHost = { - getSourceFile(name) { - if (name in files) { - const text = files[name]; - const sf = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true); - // BP requires a `version` field on each SourceFile. Production - // LS sets this from `host.getScriptVersion`; here we derive - // from content length so edited files get a different version - // (otherwise BP's diff sees no change and skips propagation). - (sf as unknown as { version: string }).version = String(text.length) + ':' + text.charCodeAt(0); - return sf; - } - if (name === realLib) { - const sf = ts.createSourceFile(realLib, libContent, ts.ScriptTarget.Latest, true); - (sf as unknown as { version: string }).version = String(libContent.length); - return sf; - } - return undefined; - }, - getDefaultLibFileName: () => realLib, - writeFile: () => {}, - getCurrentDirectory: () => '/', - getDirectories: () => [], - fileExists: n => n in files || n === realLib, - readFile: n => files[n] ?? (n === realLib ? libContent : undefined), - getCanonicalFileName: n => n, - useCaseSensitiveFileNames: () => true, - getNewLine: () => '\n', - }; - return ts.createProgram({ - rootNames: Object.keys(files), - options: { - target: ts.ScriptTarget.Latest, - noEmit: true, - incremental: true, - tsBuildInfoFile: inc.SYNTHETIC_BUILD_INFO_PATH, - lib: [libName], - }, - host, - }); -} - -function affectedFileNames(builder: ts.SemanticDiagnosticsBuilderProgram): Set { - const set = new Set(); - while (true) { - const r = builder.getSemanticDiagnosticsOfNextAffectedFile(); - if (!r) break; - if ('fileName' in r.affected) set.add(r.affected.fileName); - else for (const sf of r.affected.getSourceFiles()) set.add(sf.fileName); - } - return set; -} - -const hostShim = { - useCaseSensitiveFileNames: () => true, - getCurrentDirectory: () => '/', -}; - -// stderr capture helper for warning-path tests below. -function captureStderr(fn: () => T): { result: T; stderr: string } { - const orig = process.stderr.write.bind(process.stderr); - let buf = ''; - (process.stderr.write as any) = (chunk: any) => { - buf += String(chunk); - return true; - }; - try { - const result = fn(); - return { result, stderr: buf }; - } - finally { - (process.stderr.write as any) = orig; - } -} - -// ── Test 1: captureIncrementalState produces text on a fresh BP ───────── -{ - const program = buildProgram({ '/a.ts': 'export const x: number = 1;' }); - const builder = ts.createSemanticDiagnosticsBuilderProgram( - program, - { createHash: ts.sys.createHash }, - ); - affectedFileNames(builder); // drain - const state = inc.captureIncrementalState(ts.version, builder); - check('state captured', !!state); - check('state version is v3', state?.version === inc.INCREMENTAL_STATE_VERSION); - check('tsBuildInfoText is non-empty', !!state && state.tsBuildInfoText.length > 0); -} - -// ── Test 2: reconstructOldBuilder + diff round-trip — identical program ─ -{ - const program1 = buildProgram({ - '/a.ts': 'export const x: number = 1;', - '/b.ts': "import { x } from './a'; export const y = x + 1;", - }); - const builder1 = ts.createSemanticDiagnosticsBuilderProgram( - program1, - { createHash: ts.sys.createHash }, - ); - affectedFileNames(builder1); - const captured = inc.captureIncrementalState(ts.version, builder1)!; - - const program2 = buildProgram({ - '/a.ts': 'export const x: number = 1;', - '/b.ts': "import { x } from './a'; export const y = x + 1;", - }); - const oldBP = inc.reconstructOldBuilder(ts, captured, hostShim); - check('reconstruct produced an oldBP', !!oldBP); - const builder2 = ts.createSemanticDiagnosticsBuilderProgram( - program2, - { createHash: ts.sys.createHash }, - oldBP as ts.SemanticDiagnosticsBuilderProgram, - ); - const affected = affectedFileNames(builder2); - check( - 'identical program → no user files affected', - !affected.has('/a.ts') && !affected.has('/b.ts'), - `got affected: ${[...affected].join(', ')}`, - ); -} - -// ── Test 3: round-trip catches edits to imported file ─────────────────── -{ - const program1 = buildProgram({ - '/a.ts': 'export const x: number = 1;', - '/b.ts': "import { x } from './a'; export const y = x + 1;", - }); - const builder1 = ts.createSemanticDiagnosticsBuilderProgram( - program1, - { createHash: ts.sys.createHash }, - ); - affectedFileNames(builder1); - const captured = inc.captureIncrementalState(ts.version, builder1)!; - - // /a.ts changes its public type — should propagate to /b.ts. - const program2 = buildProgram({ - '/a.ts': 'export const x: string = "1";', - '/b.ts': "import { x } from './a'; export const y = x + 1;", - }); - const oldBP = inc.reconstructOldBuilder(ts, captured, hostShim); - const builder2 = ts.createSemanticDiagnosticsBuilderProgram( - program2, - { createHash: ts.sys.createHash }, - oldBP as ts.SemanticDiagnosticsBuilderProgram, - ); - const affected = affectedFileNames(builder2); - check('a.ts affected', affected.has('/a.ts')); - check('b.ts affected (importer of a.ts)', affected.has('/b.ts')); -} - -// ── Test 4: undefined prev → cold start (oldBP undefined) ─────────────── -{ - const oldBP = inc.reconstructOldBuilder(ts, undefined, hostShim); - check('undefined prev → undefined oldBP', oldBP === undefined); -} - -// ── Test 5: schema version mismatch → cold start ──────────────────────── -{ - const stale = { version: 'v0', tsBuildInfoText: '' }; - const oldBP = inc.reconstructOldBuilder(ts, stale, hostShim); - check('version mismatch → undefined oldBP', oldBP === undefined); -} - -// ── Test 6: corrupted tsBuildInfoText → cold start ────────────────────── -{ - const corrupted = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: '{garbage' }; - const oldBP = inc.reconstructOldBuilder(ts, corrupted, hostShim); - check('corrupted text → undefined oldBP', oldBP === undefined); -} - -// ── Test 7: TS missing internal load APIs → cold start + warn ────────── -// Future TS could rename `getBuildInfo` / -// `createBuilderProgramUsingIncrementalBuildInfo`. We must not throw, -// AND we must surface a stderr warning so users know type-aware cache -// is silently disabled. -{ - const tsStub = new Proxy(ts, { - get(target, prop) { - if (prop === 'getBuildInfo' || prop === 'createBuilderProgramUsingIncrementalBuildInfo') { - return undefined; - } - return (target as any)[prop]; - }, - }); - const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'irrelevant' }; - const { result, stderr } = captureStderr(() => { - try { - return inc.reconstructOldBuilder(tsStub, valid, hostShim); - } - catch { - return 'THREW' as const; - } - }); - check('missing load APIs → no throw', result !== 'THREW'); - check('missing load APIs → undefined result', result === undefined); - check('missing load APIs → warning printed', /warn/.test(stderr)); - check('missing load APIs → warning names the missing API', /getBuildInfo/.test(stderr)); - check('missing load APIs → warning names TS version', stderr.includes(ts.version)); -} - -// ── Test 8: BuilderProgram missing emitBuildInfo → undefined + warn ──── -// The save path must degrade gracefully if a future TS removes or -// renames `BuilderProgram.emitBuildInfo`. Otherwise the CLI throws -// after lint completes, losing all results. Must also warn the user. -{ - const fakeBuilder = {} as ts.BuilderProgram; - const { result, stderr } = captureStderr(() => { - try { - return inc.captureIncrementalState(ts.version, fakeBuilder); - } - catch { - return 'THREW' as const; - } - }); - check('missing emitBuildInfo → no throw', result !== 'THREW'); - check('missing emitBuildInfo → undefined state', result === undefined); - check('missing emitBuildInfo → warning printed', /warn/.test(stderr)); - check('missing emitBuildInfo → warning names emitBuildInfo', /emitBuildInfo/.test(stderr)); -} - -// ── Test 9: emitBuildInfo throws → undefined + warn, no throw out ────── -{ - const throwingBuilder = { - emitBuildInfo() { - throw new Error('simulated TS internal failure'); - }, - } as unknown as ts.BuilderProgram; - const { result, stderr } = captureStderr(() => { - try { - return inc.captureIncrementalState(ts.version, throwingBuilder); - } - catch { - return 'THREW' as const; - } - }); - check('throwing emitBuildInfo → no throw', result !== 'THREW'); - check('throwing emitBuildInfo → undefined state', result === undefined); - check('throwing emitBuildInfo → warning printed', /warn/.test(stderr)); - check( - 'throwing emitBuildInfo → warning includes underlying error', - /simulated TS internal failure/.test(stderr), - ); -} - -// ── Test 10: corrupted buildInfo on load throws inside getBuildInfo → -// warn, return undefined. Distinct from Test 6 — TS itself may throw on -// some corrupt inputs (vs returning undefined for others). -{ - const throwingTs = new Proxy(ts, { - get(target, prop) { - if (prop === 'getBuildInfo') { - return () => { - throw new Error('synthetic parse failure'); - }; - } - return (target as any)[prop]; - }, - }); - const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'whatever' }; - const { result, stderr } = captureStderr(() => { - try { - return inc.reconstructOldBuilder(throwingTs, valid, hostShim); - } - catch { - return 'THREW' as const; - } - }); - check('throwing getBuildInfo → no throw', result !== 'THREW'); - check('throwing getBuildInfo → undefined result', result === undefined); - check('throwing getBuildInfo → warning printed', /warn/.test(stderr)); - check( - 'throwing getBuildInfo → warning includes underlying error', - /synthetic parse failure/.test(stderr), - ); -} - -// ── Test 11: silent paths stay silent ────────────────────────────────── -// Cold start (no prev) and version mismatch are normal, not an error -// — must NOT print a warning. -{ - const { stderr: s1 } = captureStderr(() => inc.reconstructOldBuilder(ts, undefined, hostShim)); - check('undefined prev → no warning', s1 === ''); - const stale = { version: 'v0', tsBuildInfoText: '' }; - const { stderr: s2 } = captureStderr(() => inc.reconstructOldBuilder(ts, stale as any, hostShim)); - check('version mismatch → no warning', s2 === ''); -} - -// ── Test 12: oversized buildinfo → undefined + warn ──────────────────── -// Hard cap on `tsBuildInfoText` size protects against pathological -// monorepos where the captured state grows past V8's max-string limit -// or makes JSON.stringify of the surrounding cache feel sticky. Cap -// fires → warn + skip persistence (next run starts cold for layer 2). -{ - // Fabricate a builder that emits a 65MB buildinfo blob. We only - // exercise the size-guard path; the actual TS-emitted text is - // always small in practice (Dify 5867 files = ~3.6MB). - const huge = 'x'.repeat(65 * 1024 * 1024); - const oversizedBuilder = { - emitBuildInfo(write: (path: string, content: string) => void) { - write('whatever', huge); - }, - } as unknown as ts.BuilderProgram; - const { result, stderr } = captureStderr(() => { - try { - return inc.captureIncrementalState(ts.version, oversizedBuilder); - } - catch { - return 'THREW' as const; - } - }); - check('oversized buildinfo → no throw', result !== 'THREW'); - check('oversized buildinfo → undefined state', result === undefined); - check('oversized buildinfo → warning printed', /warn/.test(stderr)); - check('oversized buildinfo → warning mentions cap', /cap/.test(stderr)); -} - -// ── Done ──────────────────────────────────────────────────────────────── -process.stdout.write('\n'); -if (failures.length) { - console.error(`\n${failures.length} failure(s):`); - for (const f of failures) console.error(' - ' + f); - process.exit(1); -} -console.log('OK'); diff --git a/packages/cli/test/integration.test.ts b/packages/cli/test/integration.test.ts index a6796924..e0f390c6 100644 --- a/packages/cli/test/integration.test.ts +++ b/packages/cli/test/integration.test.ts @@ -1,8 +1,8 @@ // CLI integration tests. Spawns the local tsslint binary against a // throwaway fixture and inspects diagnostics + cache state on disk. // These complement the in-process cache-flow unit tests by exercising -// the actual lint loop, mtime check, --force gate, and cache file -// load/save round-trip. +// the actual lint loop, dependency-based invalidation, --force gate, and +// cache file load/save round-trip. // // Run via: // node packages/cli/test/integration.test.js @@ -10,6 +10,7 @@ import path = require('path'); import fs = require('fs'); import os = require('os'); +import url = require('url'); import { spawnSync } from 'child_process'; const failures: string[] = []; @@ -26,6 +27,13 @@ function check(name: string, cond: boolean, detail?: string) { const repoRoot = path.resolve(__dirname, '../../..'); const tsslintBin = path.join(repoRoot, 'packages/cli/bin/tsslint.js'); const noConsoleRule = path.join(repoRoot, 'fixtures/noConsoleRule.ts'); +const configModule = path.join(repoRoot, 'packages/config/index.js'); + +// Module specifier for an absolute path; a bare `C:\...` path is not a +// valid ESM import on Windows. +function importPath(fileName: string): string { + return JSON.stringify(url.pathToFileURL(fileName).href); +} function makeFixture(): string { // Resolve symlinks (macOS' /var → /private/var) so paths line up with @@ -46,10 +54,10 @@ function makeFixture(): string { ); fs.writeFileSync( path.join(dir, 'tsslint.config.ts'), - `import { defineConfig } from '${repoRoot}/packages/config/index.js';\n` + `import { defineConfig } from ${importPath(configModule)};\n` + `export default defineConfig({\n` + ` rules: {\n` - + ` 'no-console': (await import('${noConsoleRule}')),\n` + + ` 'no-console': (await import(${importPath(noConsoleRule)})),\n` + ` },\n` + `});\n`, ); @@ -101,18 +109,30 @@ function readCacheForFixture(fixtureDir: string): unknown { return null; } +let mtimeOffset = 0; +// Coarse filesystem timestamps could leave an edit with the same mtime. +function bumpMtime(fileName: string) { + const t = new Date(Date.now() + 60_000 * ++mtimeOffset); + fs.utimesSync(fileName, t, t); +} + +function editFile(fileName: string, text: string) { + fs.writeFileSync(fileName, text); + bumpMtime(fileName); +} + // ── Test 1: cold run produces diagnostic + writes cache ───────────────── { const dir = makeFixture(); + const fixturePath = path.join(dir, 'fixture.ts'); try { const r = runCli(dir); check('cold run included no-console diagnostic', r.stdout.includes('no-console')); - const data = readCacheForFixture(dir) as { files: any }; + const data = readCacheForFixture(dir) as any; check('cache file written for the fixture', !!data); - check( - 'cache has rule entry for fixture', - !!data?.files?.[path.join(dir, 'fixture.ts')]?.rules?.['no-console/default'], - ); + check("cache has the fixture's diagnostic", data?.files?.[fixturePath]?.diagnostics?.length === 1); + check("cache has the fixture's graph node", typeof data?.graph?.[fixturePath]?.mtime === 'number'); + check('cache has a global key', typeof data?.globalKey === 'string' && data.globalKey.length > 0); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -139,19 +159,16 @@ function readCacheForFixture(fixtureDir: string): unknown { try { runCli(dir); const before = readCacheForFixture(dir) as any; - const beforeMtime = before?.files?.[fixturePath]?.mtime; + const beforeMtime = before?.graph?.[fixturePath]?.mtime; check('cold run wrote a mtime', typeof beforeMtime === 'number'); - // Edit the file. Bump mtime explicitly so coarse filesystem - // timestamps don't cause a flaky "same mtime" hit. fs.writeFileSync(fixturePath, `console.warn('changed');\n`); - const t = new Date(Date.now() + 60_000); - fs.utimesSync(fixturePath, t, t); + bumpMtime(fixturePath); const r2 = runCli(dir); check('still produced diagnostic after edit', r2.stdout.includes('no-console')); const after = readCacheForFixture(dir) as any; - const afterMtime = after?.files?.[fixturePath]?.mtime; + const afterMtime = after?.graph?.[fixturePath]?.mtime; check( 'cache mtime moved past the edit', typeof afterMtime === 'number' && afterMtime > beforeMtime, @@ -163,34 +180,7 @@ function readCacheForFixture(fixtureDir: string): unknown { } } -// ── Test 4: editing tsslint.config.ts mints a fresh cache file ────────── -{ - const dir = makeFixture(); - const configPath = path.join(dir, 'tsslint.config.ts'); - try { - runCli(dir); - const before = readCacheForFixture(dir); - check('cache exists after cold run', !!before); - - // Touch the config so its mtime+size change. Adding a comment is - // enough — the cache key includes config mtime+size. - const original = fs.readFileSync(configPath, 'utf8'); - fs.writeFileSync(configPath, `// touched\n` + original); - - const r2 = runCli(dir); - check('still produced diagnostic with new config mtime', r2.stdout.includes('no-console')); - // The current cache file (under new key) should have entries; the - // old one is orphaned. Both might exist; verify the new run - // produced a valid cache by re-reading. - const after = readCacheForFixture(dir); - check('new cache file written under new key', !!after); - } - finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -// ── Test 5: --force bypasses cache (still produces diagnostic) ────────── +// ── Test 4: --force bypasses cache (still produces diagnostic) ────────── { const dir = makeFixture(); try { @@ -204,42 +194,33 @@ function readCacheForFixture(fixtureDir: string): unknown { } } -// ── Test 6: incrementalState always persisted (layer 2 default-on) ───── -{ - const dir = makeFixture(); - try { - const r = runCli(dir); - check('default run produced diagnostic', r.stdout.includes('no-console')); - const data = readCacheForFixture(dir) as any; - check('cache written', !!data); - check( - 'incrementalState persisted to cache file', - !!data?.incrementalState && typeof data.incrementalState.tsBuildInfoText === 'string' - && data.incrementalState.tsBuildInfoText.length > 0, - ); - } - finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -// Build a fixture that exercises layer 2: a type-aware rule that touches -// `program` (classifies it type-aware via the runtime probe), writes the -// linted file's name to a marker file each time it runs, and reports a -// fixed diagnostic. The marker file's line count tells us how many times -// the rule actually executed across runs. -function makeTypeAwareFixture(): { dir: string; markerPath: string; ambient: string; fixture: string } { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-int-l2-'))); +// A project with a dependency graph: +// +// b.ts ──▶ a.ts ◀── r.ts (re-exports a) ◀── c.ts +// u.ts (no dependencies) +// g.d.ts (global script), m.ts (`declare global`) +// +// The `type-of` rule reads the checker, reports the type of every +// variable, and appends the linted file's name to `marker.log` each time +// it runs. +function makeGraphFixture(options: { ignorePlugin?: boolean } = {}) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-int-graph-'))); const markerPath = path.join(dir, 'marker.log'); - const rulePath = path.join(dir, 'type-aware-rule.ts'); + const rulePath = path.join(dir, 'type-of-rule.ts'); fs.writeFileSync( rulePath, - `import { defineRule } from '${repoRoot}/packages/config/index.js';\n` + `import { defineRule } from ${importPath(configModule)};\n` + `import * as fs from 'fs';\n` - + `export default defineRule(({ file, program, report }) => {\n` - + ` void program.getTypeChecker();\n` + + `export default defineRule(({ typescript: ts, file, checker, report }) => {\n` + ` fs.appendFileSync(${JSON.stringify(markerPath)}, file.fileName + '\\n');\n` - + ` report('type-aware ran', 0, 1);\n` + + ` for (const statement of file.statements) {\n` + + ` if (!ts.isVariableStatement(statement)) continue;\n` + + ` for (const decl of statement.declarationList.declarations) {\n` + + ` if (!ts.isIdentifier(decl.name)) continue;\n` + + ` const type = checker.typeToString(checker.getTypeAtLocation(decl.name));\n` + + ` report(decl.name.text + ' has type ' + type, decl.name.getStart(file), decl.name.getEnd());\n` + + ` }\n` + + ` }\n` + `});\n`, ); fs.writeFileSync( @@ -252,79 +233,921 @@ function makeTypeAwareFixture(): { dir: string; markerPath: string; ambient: str strict: true, skipLibCheck: true, }, - include: ['*.ts', '*.d.ts'], + files: ['a.ts', 'b.ts', 'r.ts', 'c.ts', 'u.ts', 'g.d.ts', 'm.ts'], }), ); fs.writeFileSync( path.join(dir, 'tsslint.config.ts'), - `import { defineConfig } from '${repoRoot}/packages/config/index.js';\n` + `import { defineConfig, createIgnorePlugin } from ${importPath(configModule)};\n` + `export default defineConfig({\n` - + ` include: ['fixture.ts'],\n` - + ` rules: { 'type-aware': (await import('${rulePath}')) },\n` + + ` rules: { 'type-of': (await import(${importPath(rulePath)})).default },\n` + + (options.ignorePlugin ? ` plugins: [createIgnorePlugin('eslint-disable-next-line', true)],\n` : '') + `});\n`, ); - const ambient = path.join(dir, 'ambient.d.ts'); - const fixture = path.join(dir, 'fixture.ts'); - fs.writeFileSync(ambient, `declare const FOO: number;\n`); - fs.writeFileSync(fixture, `const z = FOO;\nexport {};\n`); - return { dir, markerPath, ambient, fixture }; + const files = { + a: path.join(dir, 'a.ts'), + b: path.join(dir, 'b.ts'), + r: path.join(dir, 'r.ts'), + c: path.join(dir, 'c.ts'), + u: path.join(dir, 'u.ts'), + g: path.join(dir, 'g.d.ts'), + m: path.join(dir, 'm.ts'), + }; + fs.writeFileSync(files.a, `export function f() {\n\treturn 1;\n}\n`); + fs.writeFileSync( + files.b, + (options.ignorePlugin + ? `import { f } from './a';\n// eslint-disable-next-line type-of\n` + : `import { f } from './a';\n`) + + `export const v = f();\n`, + ); + fs.writeFileSync(files.r, `export { f } from './a';\n`); + fs.writeFileSync(files.c, `import { f } from './r';\nexport const w = f();\n`); + fs.writeFileSync( + files.u, + (options.ignorePlugin ? `// eslint-disable-next-line nope\n` : '') + `export const u = 1;\n`, + ); + fs.writeFileSync(files.g, `declare const G: number;\n`); + fs.writeFileSync(files.m, `export {};\ndeclare global {\n\tvar M: number;\n}\n`); + return { dir, markerPath, files }; +} + +// Names of the files the rule ran on during `run`. +function lintedDuring(markerPath: string, run: () => void): string { + const before = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8').length : 0; + run(); + const after = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8') : ''; + return after.slice(before).split('\n').filter(Boolean).map(f => path.basename(f)).sort().join(','); +} + +const ALL_GRAPH_FILES = 'a.ts,b.ts,c.ts,g.d.ts,m.ts,r.ts,u.ts'; + +// ── Test 5: warm run lints nothing ────────────────────────────────────── +{ + const { dir, markerPath } = makeGraphFixture(); + try { + check('cold run lints every file', lintedDuring(markerPath, () => runCli(dir)) === ALL_GRAPH_FILES); + let out = ''; + const linted = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('warm run lints nothing', linted === '', linted); + check('warm run replays type-aware results', out.includes('v has type number'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 6: editing a function body re-lints only that file ───────────── +{ + const { dir, markerPath, files } = makeGraphFixture(); + try { + runCli(dir); + editFile(files.a, `export function f() {\n\treturn 2;\n}\n`); + const linted = lintedDuring(markerPath, () => runCli(dir)); + check('body edit re-lints only a.ts', linted === 'a.ts', linted); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 7: changing an exported type re-lints importers transitively ── +// +// b.ts imports a.ts directly; c.ts reaches it through r.ts's re-export, +// whose own declaration text doesn't change. +{ + const { dir, markerPath, files } = makeGraphFixture(); + try { + runCli(dir); + editFile(files.a, `export function f() {\n\treturn 'x';\n}\n`); + let out = ''; + const linted = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('type change re-lints a.ts, b.ts, r.ts, c.ts', linted === 'a.ts,b.ts,c.ts,r.ts', linted); + check('importer reports the new type', out.includes('v has type string'), out); + check('importer through re-export reports the new type', out.includes('w has type string'), out); + check('no stale type is reported', !/[vw] has type number/.test(out), out); + + const warm = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('warm run after type change lints nothing', warm === '', warm); + check('warm run replays the new types', out.includes('w has type string'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } -function markerLineCount(markerPath: string): number { - if (!fs.existsSync(markerPath)) return 0; - return fs.readFileSync(markerPath, 'utf8').split('\n').filter(Boolean).length; +// ── Test 8: editing a global declaration re-lints everything ──────────── +{ + const { dir, markerPath, files } = makeGraphFixture(); + try { + runCli(dir); + editFile(files.g, `declare const G: string;\n`); + const script = lintedDuring(markerPath, () => runCli(dir)); + check('global script edit re-lints every file', script === ALL_GRAPH_FILES, script); + + editFile(files.m, `export {};\ndeclare global {\n\tvar M: string;\n}\n`); + const augmentation = lintedDuring(markerPath, () => runCli(dir)); + check('`declare global` edit re-lints every file', augmentation === ALL_GRAPH_FILES, augmentation); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } -// ── Test 7 (layer 2): warm run skips type-aware rule (cache hit) ──────── +// ── Test 9: editing a global file without changing its declarations ──── +// +// A body edit leaves the declaration signature of a script or of a module +// with `declare global` / `declare module '...'` as is, so only that file +// is re-linted. A changed signature, also one that changes through an +// import, re-lints everything. { - const { dir, markerPath } = makeTypeAwareFixture(); + const { dir, markerPath, files } = makeGraphFixture(); + const tsconfigPath = path.join(dir, 'tsconfig.json'); + const script = path.join(dir, 's.ts'); + const augmentation = (body: string, type: string) => + `import type { f } from './a';\n` + + `export function h() {\n\treturn ${body};\n}\n` + + `declare global {\n\tvar M: number;\n}\n` + + `declare module './a' {\n\tinterface Extra {\n\t\tx: ${type};\n\t\ty: ReturnType;\n\t}\n}\n`; + const withScript = 'a.ts,b.ts,c.ts,g.d.ts,m.ts,r.ts,s.ts,u.ts'; try { + fs.writeFileSync(script, `function sf() {\n\treturn 1;\n}\n`); + const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); + tsconfig.files.push('s.ts'); + fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig)); + fs.writeFileSync(files.m, augmentation('1', 'number')); runCli(dir); - const afterCold = markerLineCount(markerPath); - check('cold ran rule once', afterCold === 1); + editFile(script, `function sf() {\n\treturn 2;\n}\n`); + const scriptBody = lintedDuring(markerPath, () => runCli(dir)); + check('script body edit re-lints only s.ts', scriptBody === 's.ts', scriptBody); + + editFile(files.m, augmentation('2', 'number')); + const augmentationBody = lintedDuring(markerPath, () => runCli(dir)); + check('augmenting module body edit re-lints only m.ts', augmentationBody === 'm.ts', augmentationBody); + + editFile(files.m, augmentation('2', 'string')); + const augmentationType = lintedDuring(markerPath, () => runCli(dir)); + check('`declare module` type edit re-lints every file', augmentationType === withScript, augmentationType); + + editFile(script, `function sf() {\n\treturn '';\n}\n`); + const scriptType = lintedDuring(markerPath, () => runCli(dir)); + check('script type edit re-lints every file', scriptType === withScript, scriptType); + + // m.ts's declaration text stays the same, but the type it names changes. + editFile(files.a, `export function f() {\n\treturn 'x';\n}\n`); + const propagated = lintedDuring(markerPath, () => runCli(dir)); + check('a type change reaching an augmentation re-lints every file', propagated === withScript, propagated); + const warm = lintedDuring(markerPath, () => runCli(dir)); + check('warm run after it lints nothing', warm === '', warm); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 10: a compiler option change re-lints everything ──────────────── +{ + const { dir, markerPath } = makeGraphFixture(); + const tsconfigPath = path.join(dir, 'tsconfig.json'); + try { runCli(dir); - const afterWarm = markerLineCount(markerPath); + const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); + tsconfig.compilerOptions.noUnusedLocals = true; + editFile(tsconfigPath, JSON.stringify(tsconfig)); + const linted = lintedDuring(markerPath, () => runCli(dir)); + check('compiler option change re-lints every file', linted === ALL_GRAPH_FILES, linted); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 11: config and rule module edits re-lint everything ──────────── +// +// The rule module is only reachable through the config's import. +{ + const { dir, markerPath } = makeGraphFixture(); + const configPath = path.join(dir, 'tsslint.config.ts'); + const rulePath = path.join(dir, 'type-of-rule.ts'); + try { + runCli(dir); + editFile(configPath, `// touched\n` + fs.readFileSync(configPath, 'utf8')); + const config = lintedDuring(markerPath, () => runCli(dir)); + check('config edit re-lints every file', config === ALL_GRAPH_FILES, config); + + editFile(rulePath, fs.readFileSync(rulePath, 'utf8').replace(`' has type '`, `' is typed '`)); + let out = ''; + const rule = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('rule module edit re-lints every file', rule === ALL_GRAPH_FILES, rule); + check('rule module edit reports the new message', out.includes('v is typed number'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 12: ignore plugin results stay correct on warm runs ──────────── +// +// b.ts's report is suppressed by a used comment; u.ts's comment targets a +// rule that never fires, so it's reported as unused. +{ + const { dir, markerPath, files } = makeGraphFixture({ ignorePlugin: true }); + try { + const runs: string[] = []; + runs.push(runCli(dir).stdout); + runs.push(runCli(dir).stdout); + editFile(files.a, `export function f() {\n\treturn 2;\n}\n`); + const linted = lintedDuring(markerPath, () => runs.push(runCli(dir).stdout)); + check('body edit with ignore plugin re-lints only a.ts', linted === 'a.ts', linted); + for (const [i, out] of runs.entries()) { + const label = ['cold', 'warm', 'after body edit'][i]; + check(`${label}: suppressed report stays hidden`, !out.includes('v has type'), out); + check(`${label}: used comment is not reported`, (out.match(/Unused comment/g) ?? []).length === 1, out); + check(`${label}: unused comment is reported for u.ts`, /u\.ts:1:\d+/.test(stripAnsi(out)), out); + } + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 13: a project opened through a symlink keeps precise invalidation +// +// File names in the program then differ from the real paths on disk. +{ + const { dir, markerPath, files } = makeGraphFixture(); + const link = dir + '-link'; + fs.symlinkSync(dir, link, 'dir'); + try { + runCli(link); + editFile(files.a, `export function f() {\n\treturn 2;\n}\n`); + const body = lintedDuring(markerPath, () => runCli(link)); + check('body edit through a symlink re-lints only a.ts', body === 'a.ts', body); + editFile(files.a, `export function f() {\n\treturn 'x';\n}\n`); + const type = lintedDuring(markerPath, () => runCli(link)); + check('type change through a symlink re-lints importers', type === 'a.ts,b.ts,c.ts,r.ts', type); + } + finally { + fs.rmSync(link, { force: true }); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 14: --force re-lints every file every time ───────────────────── +{ + const { dir, markerPath } = makeGraphFixture(); + try { + runCli(dir); + const linted = lintedDuring(markerPath, () => runCli(dir, '--force')); + check('--force re-lints every file', linted === ALL_GRAPH_FILES, linted); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 15: a rule reading another file's function body ───────────── +// +// The `awaits` rule looks into the body of each called function; uses.ts +// and mid.ts call `load`. typed.ts reads `load`'s type alone, so a body edit +// in lib.ts must re-lint uses.ts and mid.ts but not typed.ts. top.ts reads +// mid.ts's syntax, which stays the same. +{ + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-int-touched-'))); + const markerPath = path.join(dir, 'marker.log'); + const rulePath = path.join(dir, 'awaits-rule.ts'); + const lib = path.join(dir, 'lib.ts'); + const libText = (body: string) => `export async function load(): Promise {\n${body}\treturn 1;\n}\n`; + try { + fs.writeFileSync( + rulePath, + `import { defineRule } from ${importPath(configModule)};\n` + + `import * as fs from 'fs';\n` + + `export default defineRule(({ typescript: ts, file, checker, report }) => {\n` + + ` fs.appendFileSync(${JSON.stringify(markerPath)}, file.fileName + '\\n');\n` + + ` for (const statement of file.statements) {\n` + + ` if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) continue;\n` + + ` const alias = checker.getSymbolAtLocation(statement.expression.expression);\n` + + ` const declaration = alias && checker.getAliasedSymbol(alias).valueDeclaration?.resolve();\n` + + ` if (declaration && /\\bawait\\b/.test(declaration.getText())) {\n` + + ` report('calls a function that awaits', statement.getStart(file), statement.getEnd());\n` + + ` }\n` + + ` }\n` + + `});\n`, + ); + fs.writeFileSync( + path.join(dir, 'tsslint.config.ts'), + `import { defineConfig } from ${importPath(configModule)};\n` + + `export default defineConfig({\n` + + ` rules: { awaits: (await import(${importPath(rulePath)})).default },\n` + + `});\n`, + ); + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { target: 'es2020', module: 'esnext', moduleResolution: 'bundler', strict: true }, + files: ['lib.ts', 'mid.ts', 'top.ts', 'typed.ts', 'uses.ts'], + }), + ); + fs.writeFileSync(lib, libText('')); + fs.writeFileSync(path.join(dir, 'uses.ts'), `import { load } from './lib';\nload();\n`); + fs.writeFileSync(path.join(dir, 'typed.ts'), `import { load } from './lib';\nexport const p = load;\n`); + fs.writeFileSync(path.join(dir, 'mid.ts'), `import { load } from './lib';\nload();\nexport function run() {}\n`); + fs.writeFileSync(path.join(dir, 'top.ts'), `import { run } from './mid';\nrun();\n`); + + let out = runCli(dir).stdout; + check('cold: no report before the body awaits', !out.includes('calls a function that awaits'), out); + + editFile(lib, libText('\tawait 0;\n')); + const added = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('body edit re-lints the files reading the body', added === 'lib.ts,mid.ts,uses.ts', added); + check('the new body is reported', out.includes('calls a function that awaits'), out); + + const warm = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('warm run after the body edit lints nothing', warm === '', warm); + check('warm run replays the report', out.includes('calls a function that awaits'), out); + + editFile(lib, libText('')); + const removed = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('reverting the body re-lints the files reading it', removed === 'lib.ts,mid.ts,uses.ts', removed); + check('the report goes away', !out.includes('calls a function that awaits'), out); + + bumpMtime(lib); + const touched = lintedDuring(markerPath, () => runCli(dir)); + check('an unchanged text does not re-lint the file reading it', touched === 'lib.ts', touched); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function addGraphFiles(dir: string, files: Record) { + const tsconfigPath = path.join(dir, 'tsconfig.json'); + const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); + for (const [name, text] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), text); + tsconfig.files.push(name); + } + fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig)); +} + +// ── Test 16: a UMD module's names are global ──────────────────────────── +// +// s.ts reaches umd.d.ts's exports through `export as namespace` alone. +{ + const { dir, markerPath } = makeGraphFixture(); + const umd = path.join(dir, 'umd.d.ts'); + try { + addGraphFiles(dir, { + 'umd.d.ts': `export declare const V: number;\nexport as namespace U;\n`, + 's.ts': `const sv = U.V;\n`, + }); + runCli(dir); + editFile(umd, `export declare const V: string;\nexport as namespace U;\n`); + let out = ''; + const linted = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('UMD type change re-lints the file using its global name', linted.split(',').includes('s.ts'), linted); + check('the global name reports the new type', out.includes('sv has type string'), out); + + // Trivia between the keywords is still `export as namespace`. + editFile(umd, `export declare const V: number;\nexport /* umd */ as\n\tnamespace U;\n`); + runCli(dir); + editFile(umd, `export declare const V: boolean;\nexport /* umd */ as\n\tnamespace U;\n`); + const trivia = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('UMD with comments re-lints the file using its global name', trivia.split(',').includes('s.ts'), trivia); + check('the global name reports the type through comments', out.includes('sv has type boolean'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 17: what reaches a module's augmentation blocks ───────────────── +// +// m.ts augments './t' without importing it, names `f` from './a' in the +// block, and exports `h`, which i.ts imports. A change that doesn't reach +// the block re-lints m.ts and its importers; one that does, everything. +{ + const { dir, markerPath, files } = makeGraphFixture(); + const u = path.join(dir, 'u.ts'); + const m = (h: string, local = '', field = '') => + `import type { f } from './a';\n` + + `import { u } from './u';\n` + + `export function h() {\n\treturn ${h};\n}\n` + + local + + `declare module './t' {\n\tinterface Extra {\n\t\ty: ReturnType;${field}\n\t}\n}\n`; + const all = 'a.ts,b.ts,c.ts,g.d.ts,i.ts,m.ts,r.ts,t.ts,u.ts'; + try { + fs.writeFileSync(files.m, m('u')); + addGraphFiles(dir, { + 'i.ts': `import { h } from './m';\nexport const k = h();\n`, + 't.ts': `export const t = 1;\n`, + }); + runCli(dir); + + editFile(u, `export const u = 'x';\n`); + const imported = lintedDuring(markerPath, () => runCli(dir)); + check('an import the blocks do not name re-lints m.ts and its importers', imported === 'i.ts,m.ts,u.ts', imported); + + editFile(files.m, m('String(u).length > 0')); + const own = lintedDuring(markerPath, () => runCli(dir)); + check('an export the blocks do not name re-lints m.ts and its importers', own === 'i.ts,m.ts', own); + + editFile(path.join(dir, 't.ts'), `export const t = 1;\nexport const extra = 1;\n`); + const augmented = lintedDuring(markerPath, () => runCli(dir)); + check('a change of the augmented module re-lints everything', augmented === all, augmented); + + editFile(files.a, `export function f() {\n\treturn 'x';\n}\n`); + const named = lintedDuring(markerPath, () => runCli(dir)); + check('a change of an import the blocks name re-lints everything', named === all, named); + + const local = (type: string) => `export interface Local {\n\tz: ${type};\n}\n`; + editFile(files.m, m('String(u).length > 0', local('number'), '\n\t\tl: Local;')); + const block = lintedDuring(markerPath, () => runCli(dir)); + check('a block edit re-lints everything', block === all, block); + + editFile(files.m, m('String(u).length > 0', local('string'), '\n\t\tl: Local;')); + const named2 = lintedDuring(markerPath, () => runCli(dir)); + check('a change of a declaration the blocks name re-lints everything', named2 === all, named2); + + const warm = lintedDuring(markerPath, () => runCli(dir)); + check('warm run after it lints nothing', warm === '', warm); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 17 (per name): an augmentation block depends on what it uses ─── +// +// m.ts's block uses `homePath` from hp.ts and its own `Local`; `other` and +// `n` appear only in a comment and a string. Changing them re-lints m.ts +// and its importers; changing `homePath` or `Local`, everything. +{ + const { dir, markerPath, files } = makeGraphFixture(); + const hp = path.join(dir, 'hp.ts'); + const hpText = (home: string, other: string) => + `export function homePath() {\n\treturn ${home};\n}\n` + + `export function other() {\n\treturn ${other};\n}\n`; + const m = (n: string, local: string) => + `import { homePath, other } from './hp';\n` + + `export const o = other();\n` + + `export const n = ${n};\n` + + `export interface Local {\n\tz: ${local};\n}\n` + + `declare module './t' {\n\tinterface Extra {\n\t\t// other, n\n\t\thp: typeof homePath;\n\t\tl: Local;\n\t\ts: 'other' | 'n';\n\t}\n}\n`; + const all = 'a.ts,b.ts,c.ts,g.d.ts,hp.ts,i.ts,m.ts,r.ts,t.ts,u.ts'; + try { + fs.writeFileSync(files.m, m('1', 'number')); + addGraphFiles(dir, { + 'hp.ts': hpText(`''`, '1'), + 'i.ts': `import { o } from './m';\nexport const k = o;\n`, + 't.ts': `export const t = 1;\n`, + }); + runCli(dir); + + editFile(hp, hpText(`''`, `'x'`)); + const otherImport = lintedDuring(markerPath, () => runCli(dir)); + check('an unused import of a used module re-lints its importers', otherImport === 'hp.ts,i.ts,m.ts', otherImport); + + editFile(files.m, m(`'x'`, 'number')); + const otherLocal = lintedDuring(markerPath, () => runCli(dir)); check( - 'warm did NOT re-run rule (layer 2 cache hit)', - afterWarm === 1, - `expected 1 marker line, got ${afterWarm}`, + 'an unused declaration of the module re-lints m.ts and its importers', + otherLocal === 'i.ts,m.ts', + otherLocal, ); + + editFile(hp, hpText('1', `'x'`)); + const usedImport = lintedDuring(markerPath, () => runCli(dir)); + check('a used import re-lints everything', usedImport === all, usedImport); + + editFile(files.m, m(`'x'`, 'string')); + const usedLocal = lintedDuring(markerPath, () => runCli(dir)); + check('a used declaration re-lints everything', usedLocal === all, usedLocal); + + const warm = lintedDuring(markerPath, () => runCli(dir)); + check('warm run after it lints nothing', warm === '', warm); } finally { fs.rmSync(dir, { recursive: true, force: true }); } } -// ── Test 8 (layer 2): editing ambient.d.ts forces re-run on dependent ─── +// ── Test 17 (by symbol): a name depends on the declarations it reaches ── // -// The killer case for layer 2: ambient declaration edits don't move the -// dependent file's mtime, so layer 1 alone would silently serve stale -// type-aware results. The cross-session affected-file diff (content hash -// + transitive deps) must catch this. +// m.ts's block uses `homePath` from hp.ts, whose type is `Dir` from +// types.ts, and its own `Local`, which has a property named like m.ts's +// `other`. Changing `Other`, `other` or `hp.other` reaches neither; changing +// `Dir`, two modules away, reaches the block through `homePath`. { - const { dir, markerPath, ambient } = makeTypeAwareFixture(); + const { dir, markerPath, files } = makeGraphFixture(); + const types = path.join(dir, 'types.ts'); + const hp = path.join(dir, 'hp.ts'); + const typesText = (home: string, other: string) => `export type Dir = ${home};\nexport type Other = ${other};\n`; + const hpText = (other: string) => + `import type { Dir, Other } from './types';\n` + + `export function homePath(): Dir {\n\treturn '';\n}\n` + + `export function other(): ${other} {\n\treturn 1;\n}\n`; + const m = (other: string) => + `import { homePath } from './hp';\n` + + `export const other = ${other};\n` + + `export interface Local {\n\tother: number;\n}\n` + + `declare module './t' {\n\tinterface Extra {\n\t\thp: typeof homePath;\n\t\tl: Local;\n\t}\n}\n`; + const all = 'a.ts,b.ts,c.ts,g.d.ts,hp.ts,m.ts,r.ts,t.ts,types.ts,u.ts,user.ts'; try { + fs.writeFileSync(files.m, m('1')); + addGraphFiles(dir, { + 'types.ts': typesText('string', 'number'), + 'hp.ts': hpText('Other'), + 'user.ts': `import { homePath } from './hp';\nexport const h = homePath();\n`, + 't.ts': `export const t = 1;\n`, + }); runCli(dir); - check('cold ran rule once', markerLineCount(markerPath) === 1); - // Mutate the ambient declaration. fixture.ts's text doesn't change. - fs.writeFileSync(ambient, `declare const FOO: string;\n`); - const t = new Date(Date.now() + 60_000); - fs.utimesSync(ambient, t, t); + editFile(types, typesText('string', 'number | boolean')); + const unreached = lintedDuring(markerPath, () => runCli(dir)); + check( + 'a declaration no used name reaches re-lints only its module and importers', + unreached === 'hp.ts,types.ts', + unreached, + ); + + editFile(files.m, m(`'x'`)); + const property = lintedDuring(markerPath, () => runCli(dir)); + check('a declaration named like a used property re-lints only its module', property === 'm.ts', property); + editFile(hp, hpText('Other | undefined')); + const sibling = lintedDuring(markerPath, () => runCli(dir)); + check('an export no importer uses re-lints only its module', sibling === 'hp.ts', sibling); + + editFile(types, typesText('string | undefined', 'number | boolean')); + const deep = lintedDuring(markerPath, () => runCli(dir)); + check('a declaration a used name reaches two modules away re-lints everything', deep === all, deep); + + const warm = lintedDuring(markerPath, () => runCli(dir)); + check('warm run after it lints nothing', warm === '', warm); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 17 (unread references): a change reaches names read after it ── +// +// The first run stores names without what they reference. A change of `Dir` +// right after it still reaches far.ts, through `homePath` in hp.ts, the +// `export *` of barrel.ts and `h` in user.ts. A change of `Other`, which +// only `other` reaches, stops at the files importing it whole or by name. +{ + const { dir, markerPath } = makeGraphFixture(); + const types = path.join(dir, 'types.ts'); + const typesText = (home: string, other: string) => `export type Dir = ${home};\nexport type Other = ${other};\n`; + try { + addGraphFiles(dir, { + 'types.ts': typesText('string', 'number'), + 'hp.ts': `import type { Dir, Other } from './types';\n` + + `export function homePath(): Dir {\n\treturn '';\n}\n` + + `export function other(): Other {\n\treturn 1;\n}\n`, + 'barrel.ts': `export * from './hp';\n`, + 'user.ts': `import { homePath } from './barrel';\nexport function h() {\n\treturn homePath();\n}\n`, + 'far.ts': `import { h } from './user';\nexport const f = h()?.length;\n`, + }); runCli(dir); + + editFile(types, typesText('string | undefined', 'number')); + let out = ''; + const deep = lintedDuring(markerPath, () => out = runCli(dir).stdout); check( - 'ambient edit forced fixture.ts re-lint (layer 2 invalidation)', - markerLineCount(markerPath) === 2, - `expected 2 marker lines after ambient edit, got ${markerLineCount(markerPath)}`, + 'a change right after the first run reaches a name three modules away', + deep === 'barrel.ts,far.ts,hp.ts,types.ts,user.ts', + deep, ); + check('the name reports the new type', out.includes('f has type number | undefined'), out); - // And the cache should re-hit again on the next warm run. + editFile(types, typesText('string | undefined', 'boolean')); + const unreached = lintedDuring(markerPath, () => runCli(dir)); + check( + 'a declaration no used name reaches stops at its importers', + unreached === 'barrel.ts,hp.ts,types.ts', + unreached, + ); + + const warm = lintedDuring(markerPath, () => runCli(dir)); + check('warm run after it lints nothing', warm === '', warm); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 18: a workspace package's source behind a symlink ────────────── +// +// The program reaches core/lib.ts through node_modules, so it emits no +// declarations for it; a body edit must still keep its importer. +{ + const { dir, markerPath } = makeGraphFixture(); + const core = path.join(dir, 'core'); + const lib = path.join(core, 'lib.ts'); + try { + fs.mkdirSync(core); + fs.writeFileSync( + path.join(core, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { composite: true, strict: true }, files: ['lib.ts'] }), + ); + fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({ name: 'core', types: './lib.ts' })); + fs.writeFileSync(lib, `export function lf() {\n\treturn 1;\n}\n`); + fs.mkdirSync(path.join(dir, 'node_modules')); + fs.symlinkSync(core, path.join(dir, 'node_modules', 'core'), 'dir'); + const tsconfigPath = path.join(dir, 'tsconfig.json'); + const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); + tsconfig.files = ['use.ts']; + tsconfig.references = [{ path: './core' }]; + fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig)); + fs.writeFileSync(path.join(dir, 'use.ts'), `import { lf } from 'core';\nexport const x = lf();\n`); runCli(dir); + + editFile(lib, `export function lf() {\n\treturn 2;\n}\n`); + const body = lintedDuring(markerPath, () => runCli(dir)); + check('a body edit behind a symlink keeps the importer', body === '', body); + + editFile(lib, `export function lf() {\n\treturn 'x';\n}\n`); + let out = ''; + const type = lintedDuring(markerPath, () => out = runCli(dir).stdout); + check('a type change behind a symlink re-lints the importer', type === 'use.ts', type); + check('the importer reports the new type', out.includes('x has type string'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// A minimal tsconfig content mapper process; `transform` is the source +// of a `function transform(content)` returning the transform result. +const mapperServer = (transform: string) => ` +let input = Buffer.alloc(0); +process.stdin.on('data', chunk => { + input = Buffer.concat([input, chunk]); + while (true) { + const headerEnd = input.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length:\\s*(\\d+)/i.exec(input.subarray(0, headerEnd).toString('ascii'))[1]); + const start = headerEnd + 4; + if (input.length < start + length) return; + const message = JSON.parse(input.subarray(start, start + length).toString('utf8')); + input = input.subarray(start + length); + respond(message.id, handle(message.method, message.params)); + } +}); +function handle(method, params) { + if (method === 'initialize') return { positionEncoding: 'utf-16', diagnosticSource: 'test' }; + if (method === 'openProject') return { configIdentity: 'test' }; + if (method === 'transform') return transform(params.content); + return null; +} +function respond(id, result) { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, result })); + process.stdout.write('Content-Length: ' + body.length + '\\r\\n\\r\\n'); + process.stdout.write(body); +} +${transform} +`; + +// The `')); + return { text: body + '\\nexport {};\\n', extension: '.ts', mappings: [[0, body.length, start, body.length, 0, 1048575]] }; +} +`); + +function writeMapperProject(dir: string, server: string) { + const mapperDir = path.join(dir, 'node_modules', 'test-mapper'); + fs.mkdirSync(mapperDir, { recursive: true }); + fs.writeFileSync( + path.join(mapperDir, 'package.json'), + JSON.stringify({ + name: 'test-mapper', + version: '1.0.0', + typescript: { contentMapper: { exec: ['node', 'server.js'], dynamicConfig: true } }, + }), + ); + fs.writeFileSync(path.join(mapperDir, 'server.js'), server); + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { target: 'es2020', strict: true, skipLibCheck: true }, + contentMappers: [{ package: 'test-mapper', extensions: ['.vue'] }], + include: ['*.vue'], + }), + ); +} + +function makeContentMapperFixture(newLine: string): { dir: string; vue: string } { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-int-cm-'))); + writeMapperProject(dir, MAPPER_SERVER); + fs.writeFileSync( + path.join(dir, 'tsslint.config.ts'), + `import { defineConfig, defineRule } from ${importPath(configModule)};\n` + + `export default defineConfig({\n` + + ` rules: {\n` + + ` 'no-console': (await import(${importPath(noConsoleRule)})),\n` + + ` 'generated': defineRule(({ file, report }) => {\n` + + ` const start = file.text.lastIndexOf('export {}');\n` + + ` report('in generated code', start, start + 9).withFix('rewrite generated code', () => [\n` + + ` { pos: start, end: start + 9, newText: 'void 0;' },\n` + + ` ]);\n` + + ` }),\n` + + ` },\n` + + `});\n`, + ); + const vue = path.join(dir, 'App.vue'); + fs.writeFileSync( + vue, + ['', '', ''].join( + newLine, + ), + ); + return { dir, vue }; +} + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ''); +} + +// ── Test 16 (content mapper): diagnostics land on the original file ──── +{ + const { dir } = makeContentMapperFixture('\n'); + try { + const out = stripAnsi(runCli(dir).stdout); + check('mapped diagnostic points at App.vue:5:1', out.includes('App.vue:5:1'), out); + check('diagnostic in unmapped generated code is dropped', !out.includes('in generated code'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 17 (content mapper): --fix edits the original text, exactly ─── +// +// CRLF line endings: positions must stay in original-file coordinates. +// The fix touching generated code has no exact original span and must be +// skipped without blocking the exact one. +{ + const { dir, vue } = makeContentMapperFixture('\r\n'); + try { + const before = fs.readFileSync(vue, 'utf8'); + runCli(dir, '--fix'); + const after = fs.readFileSync(vue, 'utf8'); + check( + '--fix rewrote the mapped span in App.vue', + after === before.replace(`console.log('hi')`, '/* deleted */'), + JSON.stringify(after), + ); + const out = stripAnsi(runCli(dir).stdout); + check('no diagnostics after --fix', !out.includes('no-console'), out); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 18 (content mapper): diagnostic directives suppress reports ─── +// +// Shaped like Vue's output: template expressions are appended to the +// script inside a generated function, `@vue-ignore` / `@vue-expect-error` +// become Ignore / Expect directives over the next expression, and the +// generated code between the script and the first expression is covered +// by a synthesized Ignore (no original range). `__VLS_template` maps to +// the `template` tag name with no language features, so without the +// synthesized Ignore a report on it would land on `