diff --git a/.changeset/check-repo-typescript.md b/.changeset/check-repo-typescript.md new file mode 100644 index 0000000..b9cc572 --- /dev/null +++ b/.changeset/check-repo-typescript.md @@ -0,0 +1,14 @@ +--- +"uptide": patch +--- + +`check` compiles your code with the repository's own TypeScript (the `node_modules/typescript` +its build uses, found the way `fix` has found it since verification), and falls back to the +bundled compiler only when the repository installs none. Errors and their positions are the +ones your `tsc` would print: a repository on TypeScript 4.9 is no longer judged by TypeScript +6's rules. The coverage line says which compiler judged: `compiled 355 of 356 files in 5 +workspaces with the repo's TypeScript 4.9.5`. + +A workspace without a tsconfig of its own is now configured by the nearest one above it (a +monorepo root whose `include` covers the workspace), for its options only; synthetic defaults +apply only when no tsconfig exists anywhere. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ba4bc4..04a0131 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,7 +150,9 @@ open a **Pack request** issue, and say you are writing it. These are what make Uptide safe to run on someone else's code. A pull request that breaks one is not merged, whatever else it does. -- `check` makes no LLM call and executes nothing from the repository it reads. +- `check` makes no LLM call and runs no script or code of the repository it reads. The one + package it loads from the repository's `node_modules` is its installed `typescript`, to + compile with (as `verify` does); the bundled compiler stands in only when there is none. - Dependencies are installed with lifecycle scripts disabled; third-party code is never run outside the designed verification step of `fix`. - `fix` and `verify` work in a temporary clone. The user's checkout, services, git config diff --git a/README.md b/README.md index 60b2174..d46df74 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,14 @@ zod 3.25.76 → 4.6.5 major · latest on npm verifie stripe (packages/api) 14.25.0 → 23.0.0 9 majors behind · latest on npm verified ✗ 4 breaking in 3 files 1 auto-fixable · 3 need the agent (LLM) zod 28 breaking · compiled against 4.6.5: 28 new type errors - compiled 11 of 11 files in 2 workspaces + compiled 11 of 11 files in 2 workspaces with the repo's TypeScript 5.9.3 ✗ New error API (required_error → error) 24 sites auto-fixable ✗ ZodTypeDef removed 1 fix, 3 errors needs the agent (LLM) ✗ .ip() removed monitoring.ts:6 needs the agent (LLM) ! 15 deprecated calls (.uuid, .datetime, .email, ...) 8 auto-fixable stripe 4 breaking · compiled against 23.0.0: 4 new type errors - compiled 7 of 7 files in 1 workspace + compiled 7 of 7 files in 1 workspace with the repo's TypeScript 5.9.3 ✗ Test fixture casts widened renewal.test.ts:7 auto-fixable ✗ Subscription billing period moved to items 2 sites needs the agent (LLM) ✗ apiVersion no longer matches the SDK client.ts:9 needs the agent (LLM) @@ -216,8 +216,9 @@ compiler rejects the code against the target, the runtime probe saw the export g Everything else the declaration diff suggests is **possible impact**: the types changed where your code uses them, nothing confirmed that it breaks, so it is listed under its own line and never counted as breaking. Under every analyzed package one line says how much of the code that -uses it the compiler judged (`compiled 11 of 11 files in 2 workspaces`); when files were -skipped, the verdict says "types partly verified" and why. +uses it the compiler judged and which compiler (`compiled 11 of 11 files in 2 workspaces with +the repo's TypeScript 5.9.3`: your own `node_modules/typescript`, the bundled one only when you +install none); when files were skipped, the verdict says "types partly verified" and why. The tier is on every row of `check`, in the HTML report and in the pull request. A generic `fix` needs a provider API key (there are no rules to fall back on; without a key it says @@ -350,6 +351,9 @@ for assisted fixes in `uptide fix`, and only with your own API key from ANTHROPI OPENAI_API_KEY or GEMINI_API_KEY: for each site the rules cannot migrate, the finding, the enclosing function and the compiler error. `uptide fix --no-llm` turns assisted fixes off. No account. +`check` and `verify` load the `typescript` package your repository installs (resolved from its +node_modules) to compile with, and never run your repository's scripts or other code outside +`fix`'s verification step. Anonymous telemetry is off by default and asks for consent in an interactive terminal. Set UPTIDE_TELEMETRY=0 to disable it. No IP, code, paths or repo names are collected. Other network use: your npm registry for package metadata and tarballs, npm's advisory @@ -362,7 +366,7 @@ There is no Uptide server. With telemetry off (the default): | | Where it runs | What leaves your machine | | --- | --- | --- | | `list`, `plan` | locally | package names/versions requested from your npm registry, and `list` sends public packages' names and installed versions to npm's advisory endpoint; metadata only, no source code | -| `check` | locally | nothing of yours; it downloads package tarballs from your npm registry. No LLM call, and nothing in your repository is executed. | +| `check` | locally | nothing of yours; it downloads package tarballs from your npm registry. No LLM call. It loads your installed `typescript` package to compile with, and runs no script or other code of yours. | | `fix`, rules and verification | locally, in a temporary clone | nothing of yours | | `fix`, assisted fixes | Your chosen provider's API, with **your** environment API key | per site no rule covers: the finding, the enclosing function or declaration, and the compiler error | | `pr`, `fix --pr` | GitHub, through your own `gh` | the branch and the pull request, when you say so | diff --git a/SECURITY.md b/SECURITY.md index 0284e2f..d47cf1a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,11 +5,16 @@ isolates, what it does not, and how to report a problem. ## Isolation model -**`uptide check`** executes nothing from your repository. It reads your files and lockfile, -downloads the installed and the target version of the dependency from your npm registry as -tarballs (no install, no lifecycle scripts), compiles your code against the target in -memory, and loads the two versions of the *dependency* in a sandboxed Node process to -compare runtime behavior. It makes no LLM call. +**`uptide check`** runs none of your repository's scripts or code. It reads your files and +lockfile, downloads the installed and the target version of the dependency from your npm +registry as tarballs (no install, no lifecycle scripts), compiles your code against the +target in memory, and loads the two versions of the *dependency* in a sandboxed Node process +to compare runtime behavior. It makes no LLM call. To compile, `check` (like `verify`) loads +the `typescript` package your repository installs, resolved from its `node_modules` (the +nearest one above the workspace; never `NODE_PATH` or a global install), in Uptide's own +process; the bundled compiler is used only when the repository installs none. That package is +the one thing from your `node_modules` that `check` loads, and it is the compiler your own +build already runs. **`uptide fix`** and **`uptide verify`** work in a temporary clone, never in your checkout: @@ -25,7 +30,8 @@ compare runtime behavior. It makes no LLM call. - Git hooks are disabled for every command Uptide runs. - Verification runs **your code**: your type-check, your tests, your formatter and linter, inside the clone. This is the one place repository code is executed, and it is the point - of the command. Tests that need a database, cache or queue do not run unless you pass + of the command. The type-check loads the clone's installed `typescript` package, as + `check` does, and compiles with it in Uptide's process; no script of yours runs for it. Tests that need a database, cache or queue do not run unless you pass `--with-services --yes`, after Uptide prints what they would connect to. - Child processes for install and tests do not receive your LLM API key or GitHub token. @@ -55,7 +61,8 @@ command, and what happened that the model above says should not. You will get an answer within 7 days. Fixes are released under the `next` dist-tag first and credited to you unless you prefer otherwise. -Reports that matter most: anything executed during `check`; a lifecycle script or git hook +Reports that matter most: anything executed during `check` beyond loading the repository's +installed `typescript` package; a lifecycle script or git hook that ran; a write outside the temporary clone or `.git/uptide/`; code sent anywhere other than described above; a secret reaching a child process. diff --git a/docs/architecture.md b/docs/architecture.md index 18a8396..c7b8689 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -255,7 +255,10 @@ project references one level down) or, without one, from `**/*.ts,tsx` minus `node_modules`, `dist` and `build`. A scoped program (the files that use one package) also holds the ambient `.d.ts` files the tsconfig includes (`vite-env.d.ts`, `css.d.ts`): nothing imports them, and without them `*.module.css` imports and `declare global` names fail. -Nothing in the repository is executed. +Nothing in the repository is executed, with one deliberate load: the repository's installed +`typescript` package (resolved from its `node_modules`, see the compile section) is loaded +into Uptide's process to compile with, as `verify` has done since #40; no script or other +code of the repository runs. The compiler bundled with ts-morph is TypeScript 6, whose defaults for an option a tsconfig leaves unset differ from TypeScript 5's: `strict` on, no automatic `@types` inclusion, a @@ -352,19 +355,41 @@ inside pkg@ver, results may be incomplete"). Those never become findings: with `skipLibCheck` an unresolved import inside a declaration file makes the type `any`, which hides errors rather than inventing them. +The compiler is the repository's own (`src/adapters/typescript/compiler.ts`): the +`node_modules/typescript` in the workspace or the nearest ancestor that installs one, the way +its build and `fix`'s verification find it, never one from `NODE_PATH` or a global folder. Its +errors, at its positions, are the ones the repository's `tsc` prints; the newer bundled +compiler moves some (a JSX child's "not assignable to ReactNode" sits on the element's first +line in TypeScript 4.9 and on the child in 6) and judges by its own defaults. The bundled +compiler still parses the repository for Signal A (ts-morph is built on it), and when it is +also the one that judges (the repository installs none), the overlay shares the ts-morph +program. Otherwise a baseline program is built once per workspace with the repository's +compiler, from the ts-morph program's root files and the options that compiler reads from the +repository's tsconfig (an option a newer compiler dropped or defaults differently is the +repository's compiler's to read), with the same workspace-source `paths`; the overlay shares +that baseline instead. A workspace without a tsconfig of its own is configured by the nearest +one above it (a monorepo root whose `include` covers the workspace: excalidraw-app reads the +root's `jsx: react-jsx`), for its options only, never its file list; both programs read it, +and only a repository with no tsconfig anywhere gets synthetic defaults. The host's resolver is installed both ways the compilers ask +(`resolveModuleNameLiterals` from TypeScript 5, `resolveModuleNames` before it), and the cause +tracers take the compiler with the programs: node kinds and flags are its, not the bundled +one's. Which compiler judged is part of the coverage (`compilers`), and the coverage line names +it (`with the repo's TypeScript 4.9.5`, or `with the bundled TypeScript 6.0.2`; a monorepo +whose workspaces differ names each). + Every compile says what it covered (`CompileSignal.coverage`): the files it was asked about (the package's usage files and the files importing them, per workspace) and how many it type-checked, with a reason for each file it did not: outside the workspace's tsconfig, a workspace whose baseline is structurally broken, an invalid tsconfig. Merged across workspaces into `PackageReport.compile.coverage` (`compiled 355 of 356 files in 5 -workspaces; skipped: ...`), it is printed under every analyzed package, and a package whose +workspaces with the repo's TypeScript 4.9.5; skipped: ...`), it is printed under every analyzed package, and a package whose files were not all compiled gets the verdict "types partly verified" with that line instead of "compiled against " (`src/check/verdict.ts`). Cost is proportional to the package, not the repository. The overlay is a raw compiler `Program` that shares the baseline's parsed and bound source files and its module resolutions (only the target and its linked dependencies are parsed, once per compile); -its host canonicalizes paths exactly as ts-morph does, since `createProgram` rewrites +its host canonicalizes paths exactly as the baseline's does, since `createProgram` rewrites `file.path` on shared files. Only the files Signal A found usages in, plus the files importing those, are type-checked, on both sides; a file that neither imports the package nor imports a file that does cannot see a type of it change. Baseline diagnostics are diff --git a/docs/cli.md b/docs/cli.md index 86b4b27..afc74e9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -108,11 +108,14 @@ It is never counted as breaking. ### Coverage Under every analyzed package, one line says how much of the code that uses it the compiler -judged: `compiled 355 of 356 files in 5 workspaces; skipped: most files cannot resolve their -imports at the installed version (1)`. The files are the ones that import the package and the -ones importing those, per workspace, each compiled under its own tsconfig; a skipped one -says why (not in the workspace tsconfig, a workspace whose baseline cannot resolve its -imports, an invalid tsconfig). When not every file was compiled, the verdict says `types +judged, and with which compiler: `compiled 355 of 356 files in 5 workspaces with the repo's +TypeScript 4.9.5; skipped: most files cannot resolve their imports at the installed version +(1)`. The compiler is the repository's own `node_modules/typescript` (the one its build runs, +so the errors and their lines are the ones `tsc` would print); only a repository that installs +none is judged by the bundled one, and the line says so (`with the bundled TypeScript 6.0.2`). +The files are the ones that import the package and the ones importing those, per workspace, +each compiled under its own tsconfig; a skipped one says why (not in the workspace tsconfig, +a workspace whose baseline cannot resolve its imports, an invalid tsconfig). When not every file was compiled, the verdict says `types partly verified: compiled N of M files ...` instead of `compiled against `: a clean result only covers what was compiled. diff --git a/packages/cli/src/privacy.ts b/packages/cli/src/privacy.ts index 3b1d54c..a03c201 100644 --- a/packages/cli/src/privacy.ts +++ b/packages/cli/src/privacy.ts @@ -8,6 +8,9 @@ export const PRIVACY = `Privacy: OPENAI_API_KEY or GEMINI_API_KEY: for each site the rules cannot migrate, the finding, the enclosing function and the compiler error. \`uptide fix --no-llm\` turns assisted fixes off. No account. + \`check\` and \`verify\` load the \`typescript\` package your repository installs (resolved from its + node_modules) to compile with, and never run your repository's scripts or other code outside + \`fix\`'s verification step. Anonymous telemetry is off by default and asks for consent in an interactive terminal. Set UPTIDE_TELEMETRY=0 to disable it. No IP, code, paths or repo names are collected. Other network use: your npm registry for package metadata and tarballs, npm's advisory diff --git a/packages/core/src/adapters/typescript/cause.ts b/packages/core/src/adapters/typescript/cause.ts index 51d854e..103d151 100644 --- a/packages/core/src/adapters/typescript/cause.ts +++ b/packages/core/src/adapters/typescript/cause.ts @@ -1,5 +1,5 @@ import { relative } from 'node:path'; -import { ts } from 'ts-morph'; +import type { ts } from 'ts-morph'; import type { DiagnosticCause } from '../../domain/usage.js'; /** @@ -20,13 +20,15 @@ const MAX_HOPS = 8; interface Programs { overlay: ts.Program; base: ts.Program; + /** The compiler both programs were built with: node kinds and flags are its, not the bundled one's. */ + ts: typeof ts; } -function deepestAt(root: ts.Node, position: number): ts.Node { +function deepestAt(tsc: typeof ts, root: ts.Node, position: number): ts.Node { let node: ts.Node = root; for (;;) { let next: ts.Node | undefined; - ts.forEachChild(node, (child) => { + tsc.forEachChild(node, (child) => { if (!next && child.getStart() <= position && position < child.getEnd()) next = child; }); if (!next) return node; @@ -35,32 +37,32 @@ function deepestAt(root: ts.Node, position: number): ts.Node { } /** The expression whose type the diagnostic complains about: the identifier or access at the position. */ -function offendingExpression(node: ts.Node): ts.Expression | undefined { +function offendingExpression(tsc: typeof ts, node: ts.Node): ts.Expression | undefined { let n: ts.Node | undefined = node; - while (n && !ts.isExpression(n)) n = n.parent; + while (n && !tsc.isExpression(n)) n = n.parent; if (!n) return undefined; - while (ts.isPropertyAccessExpression(n.parent) && n.parent.name === n) n = n.parent; + while (tsc.isPropertyAccessExpression(n.parent) && n.parent.name === n) n = n.parent; return n as ts.Expression; } /** The same declaration in the other program, by file and position. */ -function counterpart(decl: ts.Node, program: ts.Program): ts.Node | undefined { +function counterpart(tsc: typeof ts, decl: ts.Node, program: ts.Program): ts.Node | undefined { const file = program.getSourceFile(decl.getSourceFile().fileName); if (!file) return undefined; - let node: ts.Node | undefined = deepestAt(file, decl.getStart()); + let node: ts.Node | undefined = deepestAt(tsc, file, decl.getStart()); while (node && node.kind !== decl.kind) node = node.parent; return node; } -function typeText(decl: ts.Node, program: ts.Program): string | undefined { - const named = ts.getNameOfDeclaration(decl as ts.Declaration); +function typeText(tsc: typeof ts, decl: ts.Node, program: ts.Program): string | undefined { + const named = tsc.getNameOfDeclaration(decl as ts.Declaration); const at = named ?? decl; const checker = program.getTypeChecker(); try { // The installed and target copies live in different directories; `import("…")` prefixes must not count as a change. return ( checker - .typeToString(checker.getTypeAtLocation(at), undefined, ts.TypeFormatFlags.NoTruncation) + .typeToString(checker.getTypeAtLocation(at), undefined, tsc.TypeFormatFlags.NoTruncation) .replace(/import\("[^"]*"\)\./g, '') // The same type prints as `ParamsDictionary` in one program and `core.ParamsDictionary` in another. .replace(/\b[A-Za-z_$][\w$]*\.(?=[A-Z])/g, '') @@ -78,8 +80,9 @@ export function findCause( /** `file:line` of every new diagnostic, so a declaration failing itself is recognised. */ erroredLines: Set, ): DiagnosticCause | undefined { + const tsc = programs.ts; if (diagnostic.start === undefined) return undefined; - if (!/\b(unknown|any)\b/.test(ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '))) + if (!/\b(unknown|any)\b/.test(tsc.flattenDiagnosticMessageText(diagnostic.messageText, ' '))) return undefined; const checker = programs.overlay.getTypeChecker(); const inRepo = (n: ts.Node): boolean => { @@ -89,12 +92,12 @@ export function findCause( const declarationOf = (expr: ts.Node): ts.Declaration | undefined => { const symbol = checker.getSymbolAtLocation(expr); const target = - symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + symbol && symbol.flags & tsc.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; return target?.declarations?.[0]; }; const lineOf = (n: ts.Node): string => { const f = n.getSourceFile(); - const named = ts.getNameOfDeclaration(n as ts.Declaration); + const named = tsc.getNameOfDeclaration(n as ts.Declaration); return `${relative(repoDir, f.fileName)}:${f.getLineAndCharacterOfPosition((named ?? n).getStart()).line + 1}`; }; const site = `${relative(repoDir, file.fileName)}:${file.getLineAndCharacterOfPosition(diagnostic.start).line + 1}`; @@ -103,12 +106,13 @@ export function findCause( const at = lineOf(decl); if (at === site) return undefined; if (erroredLines.has(at)) - return causeAt(decl, 'which itself fails to compile against the target', repoDir); - const before = counterpart(decl, programs.base); - const after = typeText(decl, programs.overlay); - const earlier = before ? typeText(before, programs.base) : undefined; + return causeAt(tsc, decl, 'which itself fails to compile against the target', repoDir); + const before = counterpart(tsc, decl, programs.base); + const after = typeText(tsc, decl, programs.overlay); + const earlier = before ? typeText(tsc, before, programs.base) : undefined; if (after !== undefined && earlier !== undefined && after !== earlier) { return causeAt( + tsc, decl, `whose type changed from \`${clip(earlier)}\` to \`${clip(after)}\``, repoDir, @@ -117,33 +121,34 @@ export function findCause( return undefined; }; const isAny = (n: ts.Node): boolean => - (checker.getTypeAtLocation(n).flags & ts.TypeFlags.Any) !== 0; + (checker.getTypeAtLocation(n).flags & tsc.TypeFlags.Any) !== 0; /** An `any` value's origin: the import or repo declaration it came from. */ const origin = (start: ts.Node): DiagnosticCause | undefined => { let n: ts.Node | undefined = start; for (let hop = 0; n && hop < MAX_HOPS; hop++) { - if (ts.isParenthesizedExpression(n) || ts.isAwaitExpression(n) || ts.isAsExpression(n)) { + if (tsc.isParenthesizedExpression(n) || tsc.isAwaitExpression(n) || tsc.isAsExpression(n)) { n = n.expression; continue; } - if (ts.isCallExpression(n) || ts.isNewExpression(n)) { - n = ts.isPropertyAccessExpression(n.expression) ? n.expression.expression : n.expression; + if (tsc.isCallExpression(n) || tsc.isNewExpression(n)) { + n = tsc.isPropertyAccessExpression(n.expression) ? n.expression.expression : n.expression; continue; } - if (ts.isPropertyAccessExpression(n)) { + if (tsc.isPropertyAccessExpression(n)) { n = n.expression; continue; } - if (!ts.isIdentifier(n)) return undefined; + if (!tsc.isIdentifier(n)) return undefined; const symbol = checker.getSymbolAtLocation(n); const decl = symbol?.declarations?.[0]; if (!decl || !inRepo(decl)) return undefined; - if (ts.isImportSpecifier(decl) || ts.isImportClause(decl) || ts.isNamespaceImport(decl)) { - const importDecl = decl.getSourceFile() && findImportDeclaration(decl); + if (tsc.isImportSpecifier(decl) || tsc.isImportClause(decl) || tsc.isNamespaceImport(decl)) { + const importDecl = decl.getSourceFile() && findImportDeclaration(tsc, decl); const from = importDecl ? importDecl.moduleSpecifier.getText().replace(/['"]/g, '') : 'an import'; return causeAt( + tsc, decl, `imported from \`${from}\`, which is typed \`any\` against the target`, repoDir, @@ -151,7 +156,7 @@ export function findCause( } const judged = judge(decl); if (judged) return judged; - if (ts.isVariableDeclaration(decl) && decl.initializer) { + if (tsc.isVariableDeclaration(decl) && decl.initializer) { n = decl.initializer; continue; } @@ -160,20 +165,20 @@ export function findCause( return undefined; }; - let expr: ts.Node | undefined = offendingExpression(deepestAt(file, diagnostic.start)); + let expr: ts.Node | undefined = offendingExpression(tsc, deepestAt(tsc, file, diagnostic.start)); for (let hop = 0; expr && hop < MAX_HOPS; hop++) { if ( - ts.isParenthesizedExpression(expr) || - ts.isAwaitExpression(expr) || - ts.isAsExpression(expr) + tsc.isParenthesizedExpression(expr) || + tsc.isAwaitExpression(expr) || + tsc.isAsExpression(expr) ) { expr = expr.expression; continue; } - if (ts.isCallExpression(expr) || ts.isNewExpression(expr)) { + if (tsc.isCallExpression(expr) || tsc.isNewExpression(expr)) { const call = expr; const callee = call.expression; - const target = ts.isPropertyAccessExpression(callee) ? callee.name : callee; + const target = tsc.isPropertyAccessExpression(callee) ? callee.name : callee; const decl = declarationOf(target); if (decl && inRepo(decl)) { const judged = judge(decl); @@ -181,45 +186,45 @@ export function findCause( // The callee is unchanged and compiles: the fault came in through an argument. const anyArg = (call.arguments ?? []).find((a) => isAny(a)); if (anyArg) return origin(anyArg); - expr = returnedExpression(decl); + expr = returnedExpression(tsc, decl); continue; } // A package callee (`schema.safeParse`): the receiver carries the type that changed. - expr = ts.isPropertyAccessExpression(callee) ? callee.expression : undefined; + expr = tsc.isPropertyAccessExpression(callee) ? callee.expression : undefined; continue; } - if (ts.isIdentifier(expr) || ts.isPropertyAccessExpression(expr)) { - const target = ts.isPropertyAccessExpression(expr) ? expr.name : expr; + if (tsc.isIdentifier(expr) || tsc.isPropertyAccessExpression(expr)) { + const target = tsc.isPropertyAccessExpression(expr) ? expr.name : expr; const decl = declarationOf(target); if (!decl || !inRepo(decl)) { - if (ts.isPropertyAccessExpression(expr)) { + if (tsc.isPropertyAccessExpression(expr)) { expr = expr.expression; continue; } - if (ts.isIdentifier(expr) && isAny(expr)) return origin(expr); + if (tsc.isIdentifier(expr) && isAny(expr)) return origin(expr); return undefined; } - if (ts.isImportSpecifier(decl) || ts.isImportClause(decl) || ts.isNamespaceImport(decl)) { + if (tsc.isImportSpecifier(decl) || tsc.isImportClause(decl) || tsc.isNamespaceImport(decl)) { return isAny(expr) ? origin(expr) : undefined; } const judged = judge(decl); if (judged) return judged; - if (ts.isVariableDeclaration(decl) || ts.isPropertyDeclaration(decl)) { + if (tsc.isVariableDeclaration(decl) || tsc.isPropertyDeclaration(decl)) { expr = decl.initializer; continue; } - if (ts.isParameter(decl)) { + if (tsc.isParameter(decl)) { const fn = decl.parent; // A callback's parameter is typed by whatever the callback was passed to; look there first. - if (ts.isCallExpression(fn.parent)) { + if (tsc.isCallExpression(fn.parent)) { expr = fn.parent; continue; } return judge(fn); } if ( - (ts.isPropertySignature(decl) || ts.isPropertyAssignment(decl)) && - ts.isPropertyAccessExpression(expr) + (tsc.isPropertySignature(decl) || tsc.isPropertyAssignment(decl)) && + tsc.isPropertyAccessExpression(expr) ) { expr = expr.expression; continue; @@ -231,26 +236,26 @@ export function findCause( return undefined; } -function findImportDeclaration(node: ts.Node): ts.ImportDeclaration | undefined { +function findImportDeclaration(tsc: typeof ts, node: ts.Node): ts.ImportDeclaration | undefined { let n: ts.Node | undefined = node; - while (n && !ts.isImportDeclaration(n)) n = n.parent; + while (n && !tsc.isImportDeclaration(n)) n = n.parent; return n; } -function returnedExpression(decl: ts.Declaration): ts.Expression | undefined { +function returnedExpression(tsc: typeof ts, decl: ts.Declaration): ts.Expression | undefined { let body: ts.Node | undefined; - if (ts.isVariableDeclaration(decl) && decl.initializer) body = decl.initializer; - else if (ts.isFunctionLike(decl) && 'body' in decl) body = (decl as { body?: ts.Node }).body; + if (tsc.isVariableDeclaration(decl) && decl.initializer) body = decl.initializer; + else if (tsc.isFunctionLike(decl) && 'body' in decl) body = (decl as { body?: ts.Node }).body; if (!body) return undefined; - if (ts.isArrowFunction(body) || ts.isFunctionExpression(body)) body = body.body; - if (body && ts.isExpression(body)) return body; + if (tsc.isArrowFunction(body) || tsc.isFunctionExpression(body)) body = body.body; + if (body && tsc.isExpression(body)) return body; let returned: ts.Expression | undefined; const visit = (n: ts.Node): void => { if (returned) return; - if (ts.isReturnStatement(n) && n.expression) returned = n.expression; - else if (!ts.isFunctionLike(n)) ts.forEachChild(n, visit); + if (tsc.isReturnStatement(n) && n.expression) returned = n.expression; + else if (!tsc.isFunctionLike(n)) tsc.forEachChild(n, visit); }; - if (body) ts.forEachChild(body, visit); + if (body) tsc.forEachChild(body, visit); return returned; } @@ -259,14 +264,14 @@ function clip(text: string): string { return one.length > 80 ? `${one.slice(0, 77)}...` : one; } -function causeAt(decl: ts.Node, reason: string, repoDir: string): DiagnosticCause { +function causeAt(tsc: typeof ts, decl: ts.Node, reason: string, repoDir: string): DiagnosticCause { const file = decl.getSourceFile(); let holder: ts.Node = decl; - let named = ts.getNameOfDeclaration(decl as ts.Declaration); - while (!named && holder.parent && !ts.isSourceFile(holder.parent)) { + let named = tsc.getNameOfDeclaration(decl as ts.Declaration); + while (!named && holder.parent && !tsc.isSourceFile(holder.parent)) { holder = holder.parent; - if (ts.isBlock(holder) || ts.isCallExpression(holder)) break; - named = ts.getNameOfDeclaration(holder as ts.Declaration); + if (tsc.isBlock(holder) || tsc.isCallExpression(holder)) break; + named = tsc.getNameOfDeclaration(holder as ts.Declaration); } const { line } = file.getLineAndCharacterOfPosition((named ?? decl).getStart()); return { @@ -298,18 +303,19 @@ export function parameterCause( /** The repository root: another workspace's source (mapped from `workspace:*`) is the repository's too. */ rootDir = repoDir, ): DiagnosticCause | undefined { + const tsc = programs.ts; if (diagnostic.start === undefined || !MISMATCH_CODES.has(diagnostic.code)) return undefined; const checker = programs.overlay.getTypeChecker(); const inRepo = (n: ts.Node): boolean => { const f = n.getSourceFile().fileName; return f.startsWith(`${rootDir}/`) && !f.includes('/node_modules/'); }; - const node = deepestAt(file, diagnostic.start); + const node = deepestAt(tsc, file, diagnostic.start); let decl: ts.Declaration | undefined; // An argument of a call: the parameter it lands on, through the resolved signature. - for (let n: ts.Node | undefined = node; n && !ts.isStatement(n); n = n.parent) { + for (let n: ts.Node | undefined = node; n && !tsc.isStatement(n); n = n.parent) { const parent: ts.Node | undefined = n.parent; - if (parent && (ts.isCallExpression(parent) || ts.isNewExpression(parent))) { + if (parent && (tsc.isCallExpression(parent) || tsc.isNewExpression(parent))) { // The callee of an inner call (`use(boxed())` reported at `boxed`): keep climbing. const index = parent.arguments?.indexOf(n as ts.Expression) ?? -1; if (index < 0) continue; @@ -319,24 +325,25 @@ export function parameterCause( decl = parameter?.valueDeclaration; break; } - if (parent && ts.isJsxAttribute(parent) && parent.initializer === n) { + if (parent && tsc.isJsxAttribute(parent) && parent.initializer === n) { const symbol = checker.getSymbolAtLocation(parent.name); decl = symbol?.declarations?.[0]; break; } } if (!decl || !inRepo(decl)) return undefined; - if (!ts.isParameter(decl) && !ts.isPropertySignature(decl) && !ts.isPropertyDeclaration(decl)) + if (!tsc.isParameter(decl) && !tsc.isPropertySignature(decl) && !tsc.isPropertyDeclaration(decl)) return undefined; const typeNode = decl.type; - if (!typeNode || !namesForeignType(typeNode, checker, inRepo)) return undefined; + if (!typeNode || !namesForeignType(tsc, typeNode, checker, inRepo)) return undefined; const site = `${relative(repoDir, file.fileName)}:${file.getLineAndCharacterOfPosition(diagnostic.start).line + 1}`; const at = decl.getSourceFile(); const declLine = `${relative(repoDir, at.fileName)}:${at.getLineAndCharacterOfPosition(decl.getStart()).line + 1}`; if (declLine === site) return undefined; - const what = ts.isParameter(decl) ? 'parameter' : 'prop'; - const name = ts.getNameOfDeclaration(decl)?.getText() ?? '(anonymous)'; + const what = tsc.isParameter(decl) ? 'parameter' : 'prop'; + const name = tsc.getNameOfDeclaration(decl)?.getText() ?? '(anonymous)'; const cause = causeAt( + tsc, decl, `whose ${what} \`${name}: ${clip(typeNode.getText())}\` no longer accepts what the target gives it; widen the ${what}'s type there`, repoDir, @@ -347,6 +354,7 @@ export function parameterCause( /** Whether a type annotation refers to a type declared outside the repository. */ function namesForeignType( + tsc: typeof ts, typeNode: ts.TypeNode, checker: ts.TypeChecker, inRepo: (n: ts.Node) => boolean, @@ -354,14 +362,14 @@ function namesForeignType( let foreign = false; const visit = (n: ts.Node): void => { if (foreign) return; - if (ts.isTypeReferenceNode(n) || ts.isExpressionWithTypeArguments(n)) { - const name = ts.isTypeReferenceNode(n) ? n.typeName : n.expression; + if (tsc.isTypeReferenceNode(n) || tsc.isExpressionWithTypeArguments(n)) { + const name = tsc.isTypeReferenceNode(n) ? n.typeName : n.expression; const symbol = checker.getSymbolAtLocation(name); const target = - symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + symbol && symbol.flags & tsc.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; if (target?.declarations?.some((d) => !inRepo(d))) foreign = true; } - ts.forEachChild(n, visit); + tsc.forEachChild(n, visit); }; visit(typeNode); return foreign; diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index b2258cf..ebc61ba 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -40,6 +40,17 @@ describe('Signal B: compile against the target version', () => { 'src/chains.ts:6 2554 Expected 2 arguments, but got 1.', ]); expect(signal.timing.overlayMs).toBeGreaterThan(0); + // Judged by the repository's own compiler: the fixture sits under this repository, whose + // installed TypeScript is the nearest one above it. + const { version } = JSON.parse( + readFileSync(join(ROOT, '../node_modules/typescript/package.json'), 'utf8'), + ) as { version: string }; + expect(signal.coverage).toEqual({ + compiled: 4, + total: 4, + skipped: [], + compilers: [{ version, own: true }], + }); }); it('subtracts pre-existing errors instead of skipping: the upgrade error is still found', async () => { diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index bba9b3b..1ccddc3 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; @@ -13,6 +13,7 @@ import type { import { satisfies } from '../../fetch/range.js'; import { onReset } from '../../shared-state.js'; import { findCause, parameterCause } from './cause.js'; +import { type Compiler, repositoryCompiler } from './compiler.js'; import { jsxNamespaceCause } from './config-cause.js'; import { type LoadedRepo, loadedRepo, ownsFile } from './repo.js'; import { @@ -43,6 +44,11 @@ import { resolvePackageDir } from './usages.js'; * parsed fresh), and only the files that use the package, plus the files importing those, * are type-checked. A file that neither imports the package nor imports a file that does * cannot see a type of it change. + * + * The compiler is the repository's own (compiler.ts): its errors, at its positions, are the + * ones the repository's build would print. The bundled compiler parses the repository for + * Signal A either way; when it is also the one that judges, the overlay shares its program, + * otherwise a baseline program is built once per workspace with the repository's compiler. */ const CANNOT_FIND_MODULE = 2307; @@ -197,6 +203,7 @@ const NOT_IN_TSCONFIG = 'not in the workspace tsconfig'; /** What became of the files asked about: compiled, or skipped and why. */ function coverageOf( + compiler: Compiler, compiled: number, missing: number, skipped?: { reason: string; count: number }, @@ -209,6 +216,7 @@ function coverageOf( compiled, total: compiled + reasons.reduce((n, r) => n + r.count, 0), skipped: reasons, + compilers: [{ version: compiler.version, own: compiler.own }], }; } @@ -284,6 +292,7 @@ function checkedOptions(base: ts.Program): ts.CompilerOptions { * checking only. Files inside `insideOverlay` are parsed fresh (once, into `parsed`). */ function sharedHost( + compiler: Compiler, base: ts.Program, insideOverlay: (file: string) => boolean, parsed: Map, @@ -299,13 +308,17 @@ function sharedHost( ) => ts.ResolvedModuleWithFailedLookupLocations | undefined) | undefined; } { + const tsc = compiler.ts; const options = checkedOptions(base); - const host = ts.createCompilerHost(options, true); - // Shared source files carry ts-morph's canonical `path`, which is case-sensitive; the - // overlay must canonicalize the same way, or `createProgram` rewrites `file.path` on the - // shared objects and the baseline program can no longer find its own module resolutions. - host.useCaseSensitiveFileNames = () => true; - host.getCanonicalFileName = (fileName) => fileName; + const host = tsc.createCompilerHost(options, true); + // Shared source files carry the baseline's canonical `path`; the overlay must canonicalize + // the same way, or `createProgram` rewrites `file.path` on the shared objects and the + // baseline program can no longer find its own module resolutions. A ts-morph baseline is + // case-sensitive whatever the file system; a native one canonicalizes as the default host does. + if (!compiler.own) { + host.useCaseSensitiveFileNames = () => true; + host.getCanonicalFileName = (fileName) => fileName; + } host.getCurrentDirectory = () => base.getCurrentDirectory(); const noPaths: ts.CompilerOptions = { ...options, paths: undefined, baseUrl: undefined }; // ts-morph serves the standard library from its own bundled copy, whose path the default @@ -314,7 +327,7 @@ function sharedHost( if (anyLib) { const libDir = dirname(anyLib.fileName); host.getDefaultLibLocation = () => libDir; - host.getDefaultLibFileName = (o) => join(libDir, ts.getDefaultLibFileName(o)); + host.getDefaultLibFileName = (o) => join(libDir, tsc.getDefaultLibFileName(o)); } const defaultFileExists = host.fileExists; host.fileExists = (fileName) => @@ -355,6 +368,7 @@ function sharedHost( * what the compiler reads, and it is read here once per file. */ function usageModes( + tsc: typeof ts, options: ts.CompilerOptions, host: ts.ModuleResolutionHost, ): (file: ts.SourceFile, usage: ts.StringLiteralLike) => ts.ResolutionMode { @@ -364,17 +378,113 @@ function usageModes( const formats = new Map(); return (file, usage) => { if (file.impliedNodeFormat !== undefined || !nodeish) - return ts.getModeForUsageLocation(file, usage, options); + return tsc.getModeForUsageLocation(file, usage, options); let format = formats.get(file.fileName); if (format === undefined && !formats.has(file.fileName)) { - format = ts.getImpliedNodeFormatForFile(file.fileName, undefined, host, options); + format = tsc.getImpliedNodeFormatForFile(file.fileName, undefined, host, options); formats.set(file.fileName, format); } // Not mutated: the baseline program keyed its own resolutions by the format it had. const withFormat = Object.create(file, { impliedNodeFormat: { value: format }, }) as ts.SourceFile; - return ts.getModeForUsageLocation(withFormat, usage, options); + return tsc.getModeForUsageLocation(withFormat, usage, options); + }; +} + +type Resolve = ( + name: string, + literal: ts.StringLiteralLike | undefined, + containingFile: string, + containingSourceFile: ts.SourceFile | undefined, +) => ts.ResolvedModuleWithFailedLookupLocations; + +/** + * One resolver, asked the way each compiler asks: TypeScript 5 and later hand over the import + * literals (`resolveModuleNameLiterals`), TypeScript 4 the names, with the file they sit in. + */ +function installResolver(host: ts.CompilerHost, resolve: Resolve): void { + host.resolveModuleNameLiterals = (literals, containingFile, _r, _o, containingSourceFile) => + literals.map((l) => resolve(l.text, l, containingFile, containingSourceFile)); + host.resolveModuleNames = (names, containingFile, _reused, _redirect, _o, containingSourceFile) => + names.map((name) => { + // The file's import literals are kept by the parser (`imports` is internal, and older + // than TypeScript 4 itself); the one for this name says which resolution mode applies. + const literal = ( + containingSourceFile as { imports?: readonly ts.StringLiteralLike[] } | undefined + )?.imports?.find((l) => l.text === name); + return resolve(name, literal, containingFile, containingSourceFile).resolvedModule; + }); +} + +/** + * The repository's own compiler over the files the ts-morph program holds, built once per + * workspace, with the options the repository's tsconfig gives that compiler (not the ones + * the bundled compiler read into the ts-morph program: an option the newer compiler + * dropped, or defaults differently, is the repository's compiler's to read). With the + * bundled compiler, the ts-morph program itself is the baseline. + */ +let nativeBases = new WeakMap(); + +function nativeBase(repo: LoadedRepo, compiler: Compiler): ts.Program { + const morph = repo.project.getProgram().compilerObject; + if (!compiler.own) return morph; + let program = nativeBases.get(repo); + if (program) return program; + const tsc = compiler.ts; + const options: ts.CompilerOptions = { + ...nativeOptions(repo, tsc, morph.getCompilerOptions()), + noEmit: true, + skipLibCheck: true, + checkJs: true, + }; + program = tsc.createProgram({ + rootNames: [...morph.getRootFileNames()], + options, + host: tsc.createCompilerHost(options, true), + }); + nativeBases.set(repo, program); + return program; +} + +function nativeOptions( + repo: LoadedRepo, + tsc: typeof ts, + morph: ts.CompilerOptions, +): ts.CompilerOptions { + // The workspace's own tsconfig, else the one above it (repo.ts), read by this compiler. + const config = repo.tsconfig ?? repo.inheritedTsconfig; + let declared: ts.CompilerOptions | undefined; + if (config) { + try { + const parsed = tsc.readConfigFile(config, (p) => readFileSync(p, 'utf8')); + if (parsed.config) + declared = tsc.parseJsonConfigFileContent( + parsed.config, + tsc.sys, + dirname(config), + undefined, + config, + ).options; + } catch { + // Unreadable by this compiler: the synthetic options below, as for no tsconfig at all. + } + } + return { + ...(declared ?? { + target: tsc.ScriptTarget.ES2022, + module: tsc.ModuleKind.ESNext, + // `bundler` arrived with TypeScript 5; before it, `node` (Node10, value 2) is the nearest. + moduleResolution: tsc.ModuleResolutionKind.Bundler ?? (2 as ts.ModuleResolutionKind), + strict: true, + allowJs: morph.allowJs ?? false, + // The modern defaults the bundled compiler would apply. + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }), + // Workspace dependencies mapped to their source (repo.ts): the same map, whichever compiler. + ...(morph.paths ? { paths: morph.paths } : {}), + ...(config ? { configFilePath: config } : {}), }; } @@ -385,28 +495,35 @@ onReset(() => { baselines = new WeakMap(); importers = new WeakMap(); checkedBaselines = new WeakMap(); + nativeBases = new WeakMap(); }); -function checkedBaseline(repo: LoadedRepo, base: ts.Program): ts.Program { +function checkedBaseline(repo: LoadedRepo, compiler: Compiler, base: ts.Program): ts.Program { + // A native baseline is built with JavaScript checked from the start. + if (compiler.own) return base; let program = checkedBaselines.get(repo); if (program) return program; - const { options, host, resolvedByBaseline } = sharedHost(base, () => false, new Map()); - host.resolveModuleNameLiterals = (literals, containingFile, _r, _o, containingSourceFile) => - literals.map( - (literal) => - resolvedByBaseline?.( - containingSourceFile, - literal.text, - ts.getModeForUsageLocation(containingSourceFile, literal, options), - ) ?? ts.resolveModuleName(literal.text, containingFile, options, host), - ); - program = ts.createProgram({ rootNames: [...base.getRootFileNames()], options, host }); + const tsc = compiler.ts; + const { options, host, resolvedByBaseline } = sharedHost(compiler, base, () => false, new Map()); + installResolver( + host, + (name, literal, containingFile, containingSourceFile) => + (literal && containingSourceFile + ? resolvedByBaseline?.( + containingSourceFile, + name, + tsc.getModeForUsageLocation(containingSourceFile, literal, options), + ) + : undefined) ?? tsc.resolveModuleName(name, containingFile, options, host), + ); + program = tsc.createProgram({ rootNames: [...base.getRootFileNames()], options, host }); checkedBaselines.set(repo, program); return program; } function overlayProgram( repo: LoadedRepo, + compiler: Compiler, base: ts.Program, targets: Target[], overlay: string, @@ -430,110 +547,109 @@ function overlayProgram( const repoProbe = join(repo.dir, '__uptide_probe__.ts'); const unresolvedInTarget = new Set(); const unresolvedFiles = new Set(); - const { options, host, noPaths, resolvedByBaseline } = sharedHost(base, insideOverlay, parsed); - const modeOf = usageModes(options, host); - host.resolveModuleNameLiterals = ( - literals, - containingFile, - _redirect, - _opts, - containingSourceFile, - ) => - literals.map((literal) => { - const name = literal.text; - // Under Node16/NodeNext the importing file's format picks the `import` or `require` - // condition; resolving from the probe would read the probe's (CommonJS) format instead. - const mode = containingSourceFile ? modeOf(containingSourceFile, literal) : undefined; - if (targets.some((t) => isPackageSpecifier(name, t.specifier ?? t.name))) { - // The repo's own `paths` may map the package (a linked checkout, a test fixture); - // inside the overlay only the symlinked target may answer. - return ts.resolveModuleName(name, probe, noPaths, host, undefined, undefined, mode); - } - if (!insideOverlay(containingFile)) { - // Outside the overlay nothing changed: the baseline's resolution is reused, no disk lookups. - const known = resolvedByBaseline?.(containingSourceFile, name, mode); - if (known) return known; - } - if (insideOverlay(containingFile) && isBare(name)) { - // Inside the target and its linked dependencies, the overlay's node_modules answers first. - const linked = ts.resolveModuleName(name, probe, noPaths, host, undefined, undefined, mode); - if (linked.resolvedModule) return linked; - } - const from = overlayDirOf(containingFile); - const dep = packageNameOf(name); - if (from !== undefined && isBare(name)) { - // The importer declares a range: the consumer's copy (however the repo maps it) stands - // in only when it satisfies that range; otherwise the next round links a proper version. - const range = declaredRange(from, dep); - if (range !== undefined && !deps.decided.has(dep) && !deps.links.has(dep)) { - const importer = overlayDirs.get(from) as string; - const consumer = installedVersion(repo, dep); - if (consumer && isPeerOnly(from, dep)) { - // A peer is the consumer's to provide: upgrading the target leaves the consumer's - // copy where it is, so that is what the target is compiled against. Fetching the - // version the peer range asks for would compile against two copies of the peer, - // which no install has, and report errors that are not there. - deps.decided.add(dep); - if (!satisfies(consumer.version, range)) - deps.unsatisfied.push( - `${dep}@${consumer.version} is outside the peer range ${range} of ${importer}; compiled against the installed ${dep}`, - ); - } else if (!consumerCopySatisfies(importer, dep, range, consumer?.version)) { - wanted.set(dep, { range, from: importer }); - } - } - // An untyped dependency is typed by the @types package the importer declares next to - // it (vitest 5: `chai` and `@types/chai`). Nothing imports `@types/x` by name, so it - // is wanted with `x`: without it the import is `any` and the ambient namespace it - // declares (`Chai`) is missing, which shows up as errors at the consumer's call sites. - const typesDep = `@types/${dep.startsWith('@') ? dep.slice(1).replace('/', '__') : dep}`; - const typesRange = declaredRange(from, typesDep); - if (typesRange !== undefined && !deps.decided.has(typesDep) && !deps.links.has(typesDep)) { - const importer = overlayDirs.get(from) as string; - const consumer = installedVersion(repo, typesDep); - if (!consumerCopySatisfies(importer, typesDep, typesRange, consumer?.version)) - wanted.set(typesDep, { range: typesRange, from: importer }); + const tsc = compiler.ts; + const { options, host, noPaths, resolvedByBaseline } = sharedHost( + compiler, + base, + insideOverlay, + parsed, + ); + const modeOf = usageModes(tsc, options, host); + installResolver(host, (name, literal, containingFile, containingSourceFile) => { + // Under Node16/NodeNext the importing file's format picks the `import` or `require` + // condition; resolving from the probe would read the probe's (CommonJS) format instead. + const mode = + containingSourceFile && literal ? modeOf(containingSourceFile, literal) : undefined; + if (targets.some((t) => isPackageSpecifier(name, t.specifier ?? t.name))) { + // The repo's own `paths` may map the package (a linked checkout, a test fixture); + // inside the overlay only the symlinked target may answer. + return tsc.resolveModuleName(name, probe, noPaths, host, undefined, undefined, mode); + } + if (!insideOverlay(containingFile) && containingSourceFile) { + // Outside the overlay nothing changed: the baseline's resolution is reused, no disk lookups. + const known = resolvedByBaseline?.(containingSourceFile, name, mode); + if (known) return known; + } + if (insideOverlay(containingFile) && isBare(name)) { + // Inside the target and its linked dependencies, the overlay's node_modules answers first. + const linked = tsc.resolveModuleName(name, probe, noPaths, host, undefined, undefined, mode); + if (linked.resolvedModule) return linked; + } + const from = overlayDirOf(containingFile); + const dep = packageNameOf(name); + if (from !== undefined && isBare(name)) { + // The importer declares a range: the consumer's copy (however the repo maps it) stands + // in only when it satisfies that range; otherwise the next round links a proper version. + const range = declaredRange(from, dep); + if (range !== undefined && !deps.decided.has(dep) && !deps.links.has(dep)) { + const importer = overlayDirs.get(from) as string; + const consumer = installedVersion(repo, dep); + if (consumer && isPeerOnly(from, dep)) { + // A peer is the consumer's to provide: upgrading the target leaves the consumer's + // copy where it is, so that is what the target is compiled against. Fetching the + // version the peer range asks for would compile against two copies of the peer, + // which no install has, and report errors that are not there. + deps.decided.add(dep); + if (!satisfies(consumer.version, range)) + deps.unsatisfied.push( + `${dep}@${consumer.version} is outside the peer range ${range} of ${importer}; compiled against the installed ${dep}`, + ); + } else if (!consumerCopySatisfies(importer, dep, range, consumer?.version)) { + wanted.set(dep, { range, from: importer }); } } - const direct = ts.resolveModuleName( - name, - containingFile, - options, - host, - undefined, - undefined, - mode, - ); - if (direct.resolvedModule || from === undefined || !isBare(name)) return direct; - const fromRepo = ts.resolveModuleName( - name, - repoProbe, - options, - host, - undefined, - undefined, - mode, - ); - // A Node builtin is an ambient module of @types/node, which the program resolves on its own. - const builtin = name.startsWith('node:') || builtinModules.includes(dep); - if (!fromRepo.resolvedModule && !wanted.has(dep) && !builtin) { - unresolvedInTarget.add(dep); - const owner = overlayDirs.get(from); - if (owner !== undefined && targets.some((t) => t.name === owner)) - unresolvedFiles.add(relative(from, containingFile)); + // An untyped dependency is typed by the @types package the importer declares next to + // it (vitest 5: `chai` and `@types/chai`). Nothing imports `@types/x` by name, so it + // is wanted with `x`: without it the import is `any` and the ambient namespace it + // declares (`Chai`) is missing, which shows up as errors at the consumer's call sites. + const typesDep = `@types/${dep.startsWith('@') ? dep.slice(1).replace('/', '__') : dep}`; + const typesRange = declaredRange(from, typesDep); + if (typesRange !== undefined && !deps.decided.has(typesDep) && !deps.links.has(typesDep)) { + const importer = overlayDirs.get(from) as string; + const consumer = installedVersion(repo, typesDep); + if (!consumerCopySatisfies(importer, typesDep, typesRange, consumer?.version)) + wanted.set(typesDep, { range: typesRange, from: importer }); } - return fromRepo; - }); + } + const direct = tsc.resolveModuleName( + name, + containingFile, + options, + host, + undefined, + undefined, + mode, + ); + if (direct.resolvedModule || from === undefined || !isBare(name)) return direct; + const fromRepo = tsc.resolveModuleName( + name, + repoProbe, + options, + host, + undefined, + undefined, + mode, + ); + // A Node builtin is an ambient module of @types/node, which the program resolves on its own. + const builtin = name.startsWith('node:') || builtinModules.includes(dep); + if (!fromRepo.resolvedModule && !wanted.has(dep) && !builtin) { + unresolvedInTarget.add(dep); + const owner = overlayDirs.get(from); + if (owner !== undefined && targets.some((t) => t.name === owner)) + unresolvedFiles.add(relative(from, containingFile)); + } + return fromRepo; + }); // Not `oldProgram: base`: structure reuse copies the baseline's module resolutions and // silently bypasses the overlay. Sharing source files and resolutions through the host // (above) gives the same saving without that. - const program = ts.createProgram({ rootNames: [...base.getRootFileNames()], options, host }); + const program = tsc.createProgram({ rootNames: [...base.getRootFileNames()], options, host }); // `declare module 'x'` inside the target makes the compiler try to resolve `x` too; an // augmentation target is not an import and must not count as a missing dependency. for (const file of program.getSourceFiles()) { if (!insideOverlay(file.fileName)) continue; - ts.forEachChild(file, (node) => { - if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + tsc.forEachChild(file, (node) => { + if (tsc.isModuleDeclaration(node) && tsc.isStringLiteral(node.name)) { unresolvedInTarget.delete(node.name.text); if (unresolvedInTarget.size === 0) unresolvedFiles.clear(); } @@ -551,6 +667,7 @@ function overlayProgram( /** Rounds of "compile, see what the overlay imports, satisfy it"; the last program is the answer. */ async function convergedOverlay( repo: LoadedRepo, + compiler: Compiler, base: ts.Program, targets: Target[], overlay: string, @@ -560,7 +677,7 @@ async function convergedOverlay( const MAX_ROUNDS = 5; const parsed = new Map(); for (let round = 0; ; round++) { - const result = overlayProgram(repo, base, targets, overlay, deps, parsed); + const result = overlayProgram(repo, compiler, base, targets, overlay, deps, parsed); if (result.wanted.size === 0 || round === MAX_ROUNDS - 1) return result; const added = await satisfyWanted(repo, deps, result.wanted, fetcher); if (added === 0) return result; @@ -583,6 +700,7 @@ export async function compileAgainstTargets( targets: Target[], options: CompileOptions = {}, ): Promise { + const compiler = repositoryCompiler(realpathSync(repoRef.dir)); let repo: LoadedRepo; try { repo = loadedRepo(realpathSync(repoRef.dir), repoRef.rootFiles); @@ -592,7 +710,10 @@ export async function compileAgainstTargets( diagnostics: [], baselineErrors: 0, skipped: `invalid tsconfig: ${message}`, - coverage: coverageOf(0, 0, { reason: 'invalid tsconfig', count: options.files?.length ?? 0 }), + coverage: coverageOf(compiler, 0, 0, { + reason: 'invalid tsconfig', + count: options.files?.length ?? 0, + }), unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], @@ -601,7 +722,7 @@ export async function compileAgainstTargets( }; } const t0 = Date.now(); - const base = repo.project.getProgram().compilerObject; + const base = nativeBase(repo, compiler); // Diagnostics come from a baseline that checks JavaScript too; files and resolutions are the // ts-morph program's. With baseline targets (the @types release the installed runtime // should have), the baseline is itself an overlay, built once for this compile. @@ -621,11 +742,12 @@ export async function compileAgainstTargets( let checked: ts.Program; const memoized = baselineTargets.length === 0; if (memoized) { - checked = checkedBaseline(repo, base); + checked = checkedBaseline(repo, compiler, base); } else { baselineOverlayDir = mkdtempSync(join(tmpdir(), 'uptide-baseline-')); checked = overlayProgram( repo, + compiler, base, baselineTargets, baselineOverlayDir, @@ -647,7 +769,10 @@ export async function compileAgainstTargets( diagnostics: [], baselineErrors: baseline.length, skipped, - coverage: coverageOf(0, missing, { reason: skipReasonOf(skipped), count: files.length }), + coverage: coverageOf(compiler, 0, missing, { + reason: skipReasonOf(skipped), + count: files.length, + }), unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], @@ -664,6 +789,7 @@ export async function compileAgainstTargets( const t1 = Date.now(); const { program, unresolvedInTarget, unresolvedFiles } = await convergedOverlay( repo, + compiler, base, targets, overlay, @@ -687,8 +813,9 @@ export async function compileAgainstTargets( ); // One compiler option can explain hundreds of diagnostics (the JSX namespace the target no // longer declares): those are anchored at the option, the rest traced to a declaration. + const programs = { overlay: program, base: checked, ts: compiler.ts }; const jsx = jsxNamespaceCause( - { overlay: program, base: checked }, + programs, fresh.map(({ d }) => d), repo.dir, targets.map((t) => t.name), @@ -698,13 +825,7 @@ export async function compileAgainstTargets( const byParameter = new Map(); const parameterOf = new Map(); for (const { d, overlaid } of fresh) { - const cause = parameterCause( - { overlay: program, base: checked }, - d, - overlaid, - repo.dir, - repoRef.root ?? repo.dir, - ); + const cause = parameterCause(programs, d, overlaid, repo.dir, repoRef.root ?? repo.dir); if (!cause) continue; const key = `${cause.file}:${cause.line}:${cause.name}`; byParameter.set(key, cause); @@ -720,7 +841,7 @@ export async function compileAgainstTargets( (parameter !== undefined && (shared.get(parameter) ?? 0) >= 2 ? byParameter.get(parameter) : undefined) ?? - findCause({ overlay: program, base: checked }, d, overlaid, repo.dir, erroredLines); + findCause(programs, d, overlaid, repo.dir, erroredLines); if (cause) diagnostic.cause = cause; return diagnostic; }); @@ -729,7 +850,7 @@ export async function compileAgainstTargets( (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column, ), baselineErrors: baseline.length, - coverage: coverageOf(files.length, missing), + coverage: coverageOf(compiler, files.length, missing), unresolvedInTarget: [...unresolvedInTarget].sort(), unresolvedFiles: [...unresolvedFiles].sort(), linkedDependencies: deps.linked, diff --git a/packages/core/src/adapters/typescript/compiler.ts b/packages/core/src/adapters/typescript/compiler.ts new file mode 100644 index 0000000..4b1484a --- /dev/null +++ b/packages/core/src/adapters/typescript/compiler.ts @@ -0,0 +1,84 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { ts } from 'ts-morph'; +import { UptideError } from '../../errors.js'; +import { onReset } from '../../shared-state.js'; + +/** + * Which TypeScript judges a repository. The repository's own compiler is authoritative: it + * is the one its build runs, so its errors (and their positions, which moved between + * TypeScript 4 and 6) are the ones the repository would see. The compiler ts-morph bundles + * stands in only when the repository installs none. + */ + +/** + * The repository's own TypeScript: `node_modules/typescript` in `dir` or the nearest ancestor + * that has one, as its own build would find it. Never `NODE_PATH` or Node's global folders: + * a compiler the repository did not install is not its compiler, and verifying with one + * reports errors (or deprecations) the repository never sees. + */ +export function consumerCompilerDir(dir: string): string | undefined { + for (let current = resolve(dir); ; current = dirname(current)) { + const manifest = join(current, 'node_modules', 'typescript', 'package.json'); + if (existsSync(manifest)) return dirname(manifest); + if (dirname(current) === current) return undefined; + } +} + +function loadCompiler(packageDir: string): typeof ts { + return createRequire(join(packageDir, 'package.json'))(packageDir) as typeof ts; +} + +/** The consumer's compiler is authoritative; bundles carry ts-morph's compiler as fallback. */ +export function resolveCompiler( + dir: string, + bundled: typeof ts | undefined = ts, + load: (packageDir: string) => typeof ts = loadCompiler, + find: (dir: string) => string | undefined = consumerCompilerDir, +): typeof ts { + try { + const own = find(dir); + if (own === undefined) throw new Error('the repository installs no TypeScript'); + return load(own); + } catch { + if (bundled) return bundled; + throw new UptideError( + 'TYPESCRIPT_UNAVAILABLE', + 'TypeScript compiler unavailable: install typescript in the consumer repository or reinstall the Uptide bundle', + ); + } +} + +/** A compiler and where it came from, as the coverage line names it. */ +export interface Compiler { + ts: typeof ts; + version: string; + /** The repository's own install, not the bundled fallback. */ + own: boolean; +} + +const compilers = new Map(); +// A loaded compiler module carries no analysis state; dropped with the rest all the same. +onReset(() => compilers.clear()); + +/** + * The compiler `check` compiles a repository with: its own, loaded once per install + * directory (a monorepo's workspaces share the hoisted one), else the bundled one. + */ +export function repositoryCompiler(dir: string): Compiler { + const own = consumerCompilerDir(dir); + const key = own ?? ''; + let compiler = compilers.get(key); + if (!compiler) { + const loaded = own === undefined ? ts : resolveCompiler(dir, ts, loadCompiler, () => own); + compiler = { ts: loaded, version: loaded.version, own: loaded !== ts }; + compilers.set(key, compiler); + } + return compiler; +} + +/** `the repo's TypeScript 4.9.5`, or `the bundled TypeScript 6.0.2`. */ +export function describeCompiler(c: { version: string; own: boolean }): string { + return `${c.own ? "the repo's" : 'the bundled'} TypeScript ${c.version}`; +} diff --git a/packages/core/src/adapters/typescript/config-cause.test.ts b/packages/core/src/adapters/typescript/config-cause.test.ts index a036d12..209f98c 100644 --- a/packages/core/src/adapters/typescript/config-cause.test.ts +++ b/packages/core/src/adapters/typescript/config-cause.test.ts @@ -47,7 +47,7 @@ describe('the JSX namespace a compiler option reads', () => { overlay.getSourceFile(join(root, 'src', 'App.tsx')), ); expect(fresh.map((d) => d.code)).toEqual([7026]); - const found = jsxNamespaceCause({ overlay, base }, fresh, root, ['@types/react']); + const found = jsxNamespaceCause({ overlay, base, ts }, fresh, root, ['@types/react']); expect(found?.cause).toMatchObject({ name: 'jsx', file: 'tsconfig.json', @@ -66,7 +66,7 @@ describe('the JSX namespace a compiler option reads', () => { // Nothing new: the base program has the namespace, and so does this "overlay". expect( jsxNamespaceCause( - { overlay: base, base }, + { overlay: base, base, ts }, base.getSemanticDiagnostics(base.getSourceFile(app)), root, ['x'], @@ -79,7 +79,7 @@ describe('the JSX namespace a compiler option reads', () => { // With an import source the namespace comes from the runtime module: another cause entirely. expect( jsxNamespaceCause( - { overlay: explicit.overlay, base: explicit.base }, + { overlay: explicit.overlay, base: explicit.base, ts }, diagnostics, explicit.root, ['x'], @@ -87,7 +87,7 @@ describe('the JSX namespace a compiler option reads', () => { ).toBeUndefined(); // Nothing that says the namespace is missing: nothing to anchor. expect( - jsxNamespaceCause({ overlay, base }, [{ code: 2322 } as ts.Diagnostic], root, ['x']), + jsxNamespaceCause({ overlay, base, ts }, [{ code: 2322 } as ts.Diagnostic], root, ['x']), ).toBeUndefined(); }); }); diff --git a/packages/core/src/adapters/typescript/config-cause.ts b/packages/core/src/adapters/typescript/config-cause.ts index 62fab37..ef5236d 100644 --- a/packages/core/src/adapters/typescript/config-cause.ts +++ b/packages/core/src/adapters/typescript/config-cause.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; -import { ts } from 'ts-morph'; +import type { ts } from 'ts-morph'; import type { DiagnosticCause } from '../../domain/usage.js'; /** @@ -18,10 +18,15 @@ const JSX_NAMESPACE_CODES = new Set([7026, 2602]); interface Programs { overlay: ts.Program; base: ts.Program; + /** The compiler the programs were built with. */ + ts: typeof ts; } /** The nearest config in the `extends` chain that sets `compilerOptions.jsx`, and the line of that setting. */ -function jsxSettingLocation(configFile: string): { file: string; line: number } | undefined { +function jsxSettingLocation( + tsc: typeof ts, + configFile: string, +): { file: string; line: number } | undefined { const seen = new Set(); for (let file = configFile; !seen.has(file); ) { seen.add(file); @@ -31,7 +36,7 @@ function jsxSettingLocation(configFile: string): { file: string; line: number } } catch { return undefined; } - const parsed = ts.parseConfigFileTextToJson(file, text).config as + const parsed = tsc.parseConfigFileTextToJson(file, text).config as | { compilerOptions?: { jsx?: unknown }; extends?: string | string[] } | undefined; if (parsed?.compilerOptions?.jsx !== undefined) { @@ -50,9 +55,9 @@ function jsxSettingLocation(configFile: string): { file: string; line: number } return undefined; } -function hasGlobalJsx(program: ts.Program): boolean { +function hasGlobalJsx(tsc: typeof ts, program: ts.Program): boolean { const checker = program.getTypeChecker(); - return checker.resolveName('JSX', undefined, ts.SymbolFlags.Namespace, false) !== undefined; + return checker.resolveName('JSX', undefined, tsc.SymbolFlags.Namespace, false) !== undefined; } /** @@ -67,18 +72,19 @@ export function jsxNamespaceCause( targets: readonly string[], ): { cause: DiagnosticCause; explains: (d: ts.Diagnostic) => boolean } | undefined { if (!fresh.some((d) => JSX_NAMESPACE_CODES.has(d.code))) return undefined; + const tsc = programs.ts; const options = programs.overlay.getCompilerOptions(); const jsx = options.jsx; // react-jsx reads the namespace from the runtime module, not the global: another cause. - if (jsx === undefined || jsx === ts.JsxEmit.ReactJSX || jsx === ts.JsxEmit.ReactJSXDev) + if (jsx === undefined || jsx === tsc.JsxEmit.ReactJSX || jsx === tsc.JsxEmit.ReactJSXDev) return undefined; if (options.jsxImportSource !== undefined) return undefined; - if (hasGlobalJsx(programs.overlay) || !hasGlobalJsx(programs.base)) return undefined; + if (hasGlobalJsx(tsc, programs.overlay) || !hasGlobalJsx(tsc, programs.base)) return undefined; const configFile = options.configFilePath as string | undefined; - const location = configFile ? jsxSettingLocation(configFile) : undefined; + const location = configFile ? jsxSettingLocation(tsc, configFile) : undefined; const file = location?.file ?? configFile; if (!file) return undefined; - const setting = ts.JsxEmit[jsx]?.toLowerCase() ?? String(jsx); + const setting = tsc.JsxEmit[jsx]?.toLowerCase() ?? String(jsx); const who = targets.find((t) => t.startsWith('@types/')) ?? targets[0] ?? 'the target'; return { cause: { diff --git a/packages/core/src/adapters/typescript/repo-inherited.test.ts b/packages/core/src/adapters/typescript/repo-inherited.test.ts new file mode 100644 index 0000000..b910ff2 --- /dev/null +++ b/packages/core/src/adapters/typescript/repo-inherited.test.ts @@ -0,0 +1,38 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ts } from 'ts-morph'; +import { expect, it } from 'vitest'; +import { inheritedTsconfigOf, loadRepo } from './repo.js'; + +it('a workspace without a tsconfig takes the options of the one above it, not its files', () => { + // excalidraw-app has no tsconfig; the root's `include` covers it, with `jsx: react-jsx`. + const root = mkdtempSync(join(tmpdir(), 'uptide-inherited-')); + writeFileSync(join(root, 'package.json'), '{"name":"root"}'); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { jsx: 'react-jsx', esModuleInterop: true, strict: false }, + include: ['packages', 'app'], + }), + ); + const app = join(root, 'app'); + mkdirSync(join(app, 'src'), { recursive: true }); + writeFileSync(join(app, 'package.json'), '{"name":"app"}'); + writeFileSync(join(app, 'src', 'index.tsx'), 'export const x =
;\n'); + mkdirSync(join(root, 'packages', 'lib'), { recursive: true }); + writeFileSync(join(root, 'packages', 'lib', 'index.ts'), 'export const lib = 1;\n'); + expect(inheritedTsconfigOf(app)).toBe(join(root, 'tsconfig.json')); + expect(inheritedTsconfigOf(root)).toBeUndefined(); + const repo = loadRepo(app); + expect(repo.tsconfig).toBeUndefined(); + expect(repo.inheritedTsconfig).toBe(join(root, 'tsconfig.json')); + const options = repo.project.getCompilerOptions(); + expect(options.jsx).toBe(ts.JsxEmit.ReactJSX); + expect(options.esModuleInterop).toBe(true); + expect(options.strict).toBe(false); + // The root's file list is not the workspace's: only its own sources are loaded. + expect(repo.project.getSourceFiles().map((f) => f.getFilePath())).toEqual([ + join(app, 'src', 'index.tsx'), + ]); +}); diff --git a/packages/core/src/adapters/typescript/repo.ts b/packages/core/src/adapters/typescript/repo.ts index 6816e06..ccc4dee 100644 --- a/packages/core/src/adapters/typescript/repo.ts +++ b/packages/core/src/adapters/typescript/repo.ts @@ -28,7 +28,10 @@ export interface LoadedRepo { /** name -> exact installed version for this package's direct dependencies (from the lockfile). */ installed: Map; project: Project; + /** The repository's own tsconfig.json, when it has one. */ tsconfig: string | undefined; + /** Without one, the nearest tsconfig.json above it (a monorepo root's that includes this workspace): its options apply, not its file list. */ + inheritedTsconfig: string | undefined; /** `allowJs` from the tsconfig: whether `.js`/`.jsx` files are part of the project and the scan. */ includesJs: boolean; /** Workspace dependencies compiled from their built output because no source could be mapped, when that output is stale. */ @@ -48,6 +51,15 @@ export function findRepoRoot(cwd: string): string | undefined { const SYNTHETIC_EXCLUDES = ['**/node_modules/**', '**/dist/**', '**/build/**']; +/** The nearest tsconfig.json strictly above `dir`, as a workspace without its own is configured by. */ +export function inheritedTsconfigOf(dir: string): string | undefined { + for (let current = dirname(resolve(dir)); ; current = dirname(current)) { + const candidate = join(current, 'tsconfig.json'); + if (existsSync(candidate)) return candidate; + if (dirname(current) === current) return undefined; + } +} + const repos = new Map(); // A program or checker a failed analysis was inside may be half updated: reload them. onReset(() => repos.clear()); @@ -171,13 +183,15 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { const sources = workspaceSourceMap(dir, installed); const sourcePaths = Object.keys(sources.paths).length > 0 ? sources.paths : undefined; let project: Project; + // A repository on TypeScript 5 is read with TypeScript 5's defaults for what its tsconfig + // leaves unset (legacy-options.ts); one on TypeScript 6 with the compiler's own. + const major = repositoryTypescriptMajor(dir); + const legacyDefaults = (config: string): ts.CompilerOptions => + major === undefined || major < 6 ? typescriptFiveDefaults(declaredOptions(config)) : {}; + const inherited = existsSync(tsconfig) ? undefined : inheritedTsconfigOf(dir); if (existsSync(tsconfig)) { const declared = sourcePaths ? declaredPaths(tsconfig) : { paths: {} }; - // A repository on TypeScript 5 is read with TypeScript 5's defaults for what its tsconfig - // leaves unset (legacy-options.ts); one on TypeScript 6 with the compiler's own. - const major = repositoryTypescriptMajor(dir); - const legacy = - major === undefined || major < 6 ? typescriptFiveDefaults(declaredOptions(tsconfig)) : {}; + const legacy = legacyDefaults(tsconfig); project = new Project({ tsConfigFilePath: tsconfig, skipAddingFilesFromTsConfig: rootFiles !== undefined, @@ -199,6 +213,8 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { } } } else { + // No tsconfig of its own: the options of the one above it (a monorepo root's `include` + // often covers the workspace), over the defaults below; the file list is the workspace's. project = new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true, @@ -207,9 +223,10 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, + allowJs: false, + ...(inherited ? { ...legacyDefaults(inherited), ...declaredOptions(inherited) } : {}), noEmit: true, skipLibCheck: true, - allowJs: false, ...(sourcePaths ? { paths: sourcePaths } : {}), }, }); @@ -235,6 +252,7 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { installed, project, tsconfig: existsSync(tsconfig) ? tsconfig : undefined, + inheritedTsconfig: inherited, includesJs: project.getCompilerOptions().allowJs === true, warnings: sources.warnings, }; diff --git a/packages/core/src/check/check.test.ts b/packages/core/src/check/check.test.ts index 722af3b..67b8dc4 100644 --- a/packages/core/src/check/check.test.ts +++ b/packages/core/src/check/check.test.ts @@ -437,6 +437,41 @@ describe('mergeAcrossWorkspaces', () => { status: 'safe', }); }); + + it('merges coverage across workspaces, naming each compiler once', () => { + const compile = (version: string, own: boolean) => ({ + baselineErrors: 0, + unresolvedInTarget: [], + unresolvedFiles: [], + unattributed: [], + newErrors: 0, + coverage: { + compiled: 1, + total: 1, + workspaces: 1, + skipped: [], + compilers: [{ version, own }], + }, + }); + const out = mergeAcrossWorkspaces( + [ + base('packages/api', 'zod', { compile: compile('4.9.5', true) }), + base('packages/web', 'zod', { compile: compile('4.9.5', true) }), + base('packages/shared', 'zod', { compile: compile('6.0.2', false) }), + ], + {}, + ); + expect(out[0]?.compile?.coverage).toEqual({ + compiled: 3, + total: 3, + workspaces: 3, + skipped: [], + compilers: [ + { version: '4.9.5', own: true }, + { version: '6.0.2', own: false }, + ], + }); + }); }); describe('summarize', () => { diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index c8e36b7..ac2c5f3 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -518,6 +518,15 @@ export function summarize(packages: PackageReport[]): CheckReport['summary'] { }; } +/** One entry per compiler the workspaces were judged with. */ +function uniqueCompilers( + compilers: { version: string; own: boolean }[], +): { version: string; own: boolean }[] { + const seen = new Map(); + for (const c of compilers) seen.set(`${c.own}:${c.version}`, c); + return [...seen.values()]; +} + /** * One dependency, one decision. A dependency at the same installed version and target in * several workspaces (a pnpm catalog entry, or plain duplication) is one entry, with the @@ -638,6 +647,9 @@ export function mergeAcrossWorkspaces( total: coverages.reduce((n, c) => n + c.total, 0), workspaces: coverages.reduce((n, c) => n + c.workspaces, 0), skipped: [...skipped].map(([reason, count]) => ({ reason, count })), + ...(coverages.some((c) => c.compilers) + ? { compilers: uniqueCompilers(coverages.flatMap((c) => c.compilers ?? [])) } + : {}), }, } : {}), diff --git a/packages/core/src/check/verdict.test.ts b/packages/core/src/check/verdict.test.ts index 7ec94d6..282d5ca 100644 --- a/packages/core/src/check/verdict.test.ts +++ b/packages/core/src/check/verdict.test.ts @@ -70,6 +70,22 @@ it('says the types are partly verified when some files that use the package were // Every file compiled: the clean verdict, with the coverage printed beside it. const full = { compiled: 1, total: 1, workspaces: 1, skipped: [] }; expect(coverageLine(full)).toBe('compiled 1 of 1 file in 1 workspace'); + // Which compiler judged: the repository's own, else the bundled one; a monorepo may use both. + expect(coverageLine({ ...full, compilers: [{ version: '4.9.5', own: true }] })).toBe( + "compiled 1 of 1 file in 1 workspace with the repo's TypeScript 4.9.5", + ); + expect( + coverageLine({ + ...coverage, + compilers: [ + { version: '5.4.2', own: true }, + { version: '4.9.5', own: true }, + { version: '6.0.2', own: false }, + ], + }), + ).toBe( + "compiled 12 of 40 files in 2 workspaces with the repo's TypeScript 4.9.5 and 5.4.2 and the bundled TypeScript 6.0.2; skipped: not in the workspace tsconfig (20), most files cannot resolve their imports at the installed version (8)", + ); expect(verdictOf(report({ compile: { ...compile, newErrors: 0, coverage: full } })).summary).toBe( '0 breaking · compiled against 4.6.5: 0 new type errors', ); diff --git a/packages/core/src/check/verdict.ts b/packages/core/src/check/verdict.ts index d6ee7db..9e40f04 100644 --- a/packages/core/src/check/verdict.ts +++ b/packages/core/src/check/verdict.ts @@ -19,13 +19,29 @@ export interface Verdict { const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; /** - * `compiled 12 of 40 files in 2 workspaces; skipped: not in the workspace tsconfig (28)`: how - * much of the code that uses the package the compiler judged, printed under every check. + * `compiled 12 of 40 files in 2 workspaces with the repo's TypeScript 5.4.2; skipped: not in + * the workspace tsconfig (28)`: how much of the code that uses the package the compiler + * judged, and which compiler, printed under every check. */ export function coverageLine(c: NonNullable['coverage']): string { if (!c) return ''; const skipped = c.skipped.map((r) => `${r.reason} (${r.count})`).join(', '); - return `compiled ${c.compiled} of ${count(c.total, 'file')} in ${count(c.workspaces, 'workspace')}${skipped ? `; skipped: ${skipped}` : ''}`; + const compilers = compilersPhrase(c.compilers ?? []); + return `compiled ${c.compiled} of ${count(c.total, 'file')} in ${count(c.workspaces, 'workspace')}${compilers ? ` with ${compilers}` : ''}${skipped ? `; skipped: ${skipped}` : ''}`; +} + +/** + * `the repo's TypeScript 4.9.5`; a monorepo whose workspaces install different versions + * names each (`the repo's TypeScript 4.9.5 and 5.4.2`), and one that installs none in some + * workspace says which were judged by the bundled compiler instead. + */ +function compilersPhrase(compilers: { version: string; own: boolean }[]): string { + const versions = (own: boolean) => + [...new Set(compilers.filter((c) => c.own === own).map((c) => c.version))].sort().join(' and '); + return [ + ...(versions(true) ? [`the repo's TypeScript ${versions(true)}`] : []), + ...(versions(false) ? [`the bundled TypeScript ${versions(false)}`] : []), + ].join(' and '); } /** diff --git a/packages/core/src/domain/usage.ts b/packages/core/src/domain/usage.ts index 6f954a2..974dabb 100644 --- a/packages/core/src/domain/usage.ts +++ b/packages/core/src/domain/usage.ts @@ -139,6 +139,8 @@ export interface CompileCoverage { total: number; /** Why the rest were not compiled, one entry per reason. */ skipped: { reason: string; count: number }[]; + /** Which TypeScript judged: the repository's own install, or the bundled fallback. Absent in reports stored before it was kept. */ + compilers?: { version: string; own: boolean }[]; } /** Signal B: the repo type-checked against the target version. */ diff --git a/packages/core/src/fix/compiler.test.ts b/packages/core/src/fix/compiler.test.ts index 67ba219..f79b73f 100644 --- a/packages/core/src/fix/compiler.test.ts +++ b/packages/core/src/fix/compiler.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ts } from 'ts-morph'; import { afterAll, expect, it } from 'vitest'; -import { consumerCompilerDir, resolveCompiler } from './verify.js'; +import { + consumerCompilerDir, + describeCompiler, + repositoryCompiler, + resolveCompiler, +} from '../adapters/typescript/compiler.js'; const scratch = mkdtempSync(join(tmpdir(), 'uptide-compiler-')); afterAll(() => rmSync(scratch, { recursive: true, force: true })); @@ -69,3 +74,15 @@ it('never takes a compiler from NODE_PATH or the global folders', () => { mkdirSync(bare, { recursive: true }); expect(resolveCompiler(bare)).toBe(ts); }); + +it("names the repository's compiler for the coverage line, and the bundled one when there is none", () => { + // The scratch repository made above installs a stub; the bare directory installs nothing. + const own = repositoryCompiler(join(scratch, 'repo', 'packages', 'app')); + expect(own).toMatchObject({ version: '0.0.0-own', own: true }); + expect(describeCompiler(own)).toBe("the repo's TypeScript 0.0.0-own"); + const bundled = repositoryCompiler(join(scratch, 'bare')); + expect(bundled).toMatchObject({ ts, version: ts.version, own: false }); + expect(describeCompiler(bundled)).toBe(`the bundled TypeScript ${ts.version}`); + // Loaded once per install directory: two workspaces under one root share the module. + expect(repositoryCompiler(join(scratch, 'repo'))).toBe(own); +}); diff --git a/packages/core/src/fix/verify.ts b/packages/core/src/fix/verify.ts index a302f0b..aa9da01 100644 --- a/packages/core/src/fix/verify.ts +++ b/packages/core/src/fix/verify.ts @@ -1,49 +1,14 @@ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { builtinModules, createRequire } from 'node:module'; -import { basename, dirname, join, relative, resolve } from 'node:path'; +import { builtinModules } from 'node:module'; +import { basename, dirname, join, relative } from 'node:path'; import { Project, ts } from 'ts-morph'; +import { resolveCompiler } from '../adapters/typescript/compiler.js'; import { readInstalled } from '../adapters/typescript/repo.js'; import { workspaceSourceMap } from '../adapters/typescript/workspace-source.js'; -import { UptideError } from '../errors.js'; import { command } from './process.js'; import { isServiceTest, serviceNeeds, unitConfig } from './services.js'; import type { FixDiagnostic, TestResult } from './types.js'; -/** - * The repository's own TypeScript: `node_modules/typescript` in `dir` or the nearest ancestor - * that has one, as its own build would find it. Never `NODE_PATH` or Node's global folders: - * a compiler the repository did not install is not its compiler, and verifying with one - * reports errors (or deprecations) the repository never sees. - */ -export function consumerCompilerDir(dir: string): string | undefined { - for (let current = resolve(dir); ; current = dirname(current)) { - const manifest = join(current, 'node_modules', 'typescript', 'package.json'); - if (existsSync(manifest)) return dirname(manifest); - if (dirname(current) === current) return undefined; - } -} - -/** The consumer's compiler is authoritative; bundles carry ts-morph's compiler as fallback. */ -export function resolveCompiler( - dir: string, - bundled: typeof ts | undefined = ts, - load = (packageDir: string) => - createRequire(join(packageDir, 'package.json'))(packageDir) as typeof ts, - find: (dir: string) => string | undefined = consumerCompilerDir, -): typeof ts { - try { - const own = find(dir); - if (own === undefined) throw new Error('the repository installs no TypeScript'); - return load(own); - } catch { - if (bundled) return bundled; - throw new UptideError( - 'TYPESCRIPT_UNAVAILABLE', - 'TypeScript compiler unavailable: install typescript in the consumer repository or reinstall the Uptide bundle', - ); - } -} - let bundledLibraries: Map | undefined; function bundledHost(options: ts.CompilerOptions): ts.CompilerHost { if (!bundledLibraries?.has(ts.getDefaultLibFileName(options))) {