diff --git a/.gitignore b/.gitignore index 790c3cc26..7567f3269 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ !tests/fixtures/npm/node_modules/ !tests/corpus/*/node_modules/ !tests/fixtures/npm-static/node_modules/ +!tests/fixtures/npm-static/nested/inner/node_modules/ !tests/fixtures/npm/store/*/node_modules/ !tests/fixtures/npm/cases/*/node_modules/ !tests/fixtures/provenance/node_modules/ diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 6c73a2816..6d34fd88a 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -7,7 +7,7 @@ import type { Lowerer } from "./lowerer.js"; import { dirname as dirnamePath, resolve as resolvePath } from "node:path"; import { NpmGraphBuilder, packageNameOfPath, probeNodeImportRefusal } from "../npm.js"; import { isNpmStaticPackage } from "../npm-static.js"; -import { isJsSourceFileName } from "../shared.js"; +import { isJsSourceFileName, isRelativeSpecifier } from "../shared.js"; import { cjsExportAssignmentOf, cjsExportDiscardReason, isCjsJsFile, isJsSourceFile, isRequireStatement, locOf, orderedImportsOf, resolveImport, resolveNpmImport } from "../program.js"; import { invalidJsonModuleDiag, npmEmbedFailedDiag, requiresDynamicImportDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js"; import { BOOL, DYN, IrClassDef, IrExpr, IrFunction, IrGlobal, IrRecordShape, IrStmt, IrUnionDef, JSVAL, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, canConvertToDyn, isUnitType } from "../../ir/nodes.js"; @@ -75,7 +75,7 @@ export interface FileParts { sf: ts.SourceFile, spec: string, ): ts.SourceFile | null { - if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("/")) return null; + if (!isRelativeSpecifier(spec) && !spec.startsWith("/")) return null; const dep = resolveImport(program, sf, spec); if (!dep || dep.isDeclarationFile) return null; if (dep.fileName.endsWith(".json") || dep.fileName.endsWith(".cts")) return null; @@ -240,7 +240,7 @@ export interface FileParts { // and node_modules files never lower as program modules — the // import silently dropped). const relPkg = - npm === null && (spec.startsWith("./") || spec.startsWith("../")) + npm === null && isRelativeSpecifier(spec) ? packageNameOfPath(resolvePath(dirnamePath(fp.sf.fileName), spec)) : null; const relIsJs = diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 49953fbc9..459498921 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -24,7 +24,7 @@ import { lowerEnumDeclaration } from "./lower-enums.js"; import { abstractPropertyDeclOf, aliasTypeofNarrows, probeLower, pureReemittable, symbolFieldInfo } from "./lower-exprs.js"; import { UNSUPPORTED, checkerPanicDiag, isCheckerPanic, requiresDynamicDiag } from "../../diagnostics/diagnostic.js"; import { isUnitOnlyTsType, unitOnlyUnion } from "../types.js"; -import { canonicalBuiltinModule } from "../shared.js"; +import { canonicalBuiltinModule, isRelativeSpecifier } from "../shared.js"; import { probeNodeRequireRefusal } from "../npm.js"; /** `const X = /* @__PURE__ *\/ makeX()` at a module's top level: every @@ -3017,7 +3017,7 @@ export function lowerVarDecl(L: Lowerer, decl: ts.VariableDeclaration, isLet: bo if (isCjsJsFile(sf)) { const spec = requireSpecOf(expr)!; if ( - !spec.startsWith("./") && !spec.startsWith("../") && + !isRelativeSpecifier(spec) && canonicalBuiltinModule(spec) === null ) { const refusal = probeNodeRequireRefusal(sf.fileName, spec); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e6c7b90b8..9bb1f0e66 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -12,6 +12,7 @@ * - Lexical scoping is resolved here: locals get function-unique ids * ("x.0", "x.1" for shadowing); the IR is scope-flat. */ +import { isRelativeSpecifier } from "../shared.js"; import * as ts from "../ts7/adapter.js"; import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import { @@ -1236,7 +1237,7 @@ export class Lowerer { return null; } const spec = importDecl.moduleSpecifier.text; - if (!spec.startsWith("./") && !spec.startsWith("../")) return null; + if (!isRelativeSpecifier(spec)) return null; const dep = resolveImport(this.program, importDecl.getSourceFile(), spec); if (!dep || !isJsSourceFile(dep) || isNodeEsmFile(dep)) return null; return dep; @@ -1263,7 +1264,7 @@ export class Lowerer { return false; } const spec = requireSpecOf(decl.initializer); - if (spec === null || !(spec.startsWith("./") || spec.startsWith("../"))) return false; + if (spec === null || !isRelativeSpecifier(spec)) return false; } // SINGLE-VALUE exporters (`module.exports = Countdown` / `= double` / // `= 42`): the requirer's binding IS the exported value, not a @@ -1364,7 +1365,7 @@ export class Lowerer { * specifier is not a relative program module (builtins load nothing; * anything else kept its preflight fence). */ requireInitStmt(spec: string, node: ts.Node): IrStmt | null { - if (!spec.startsWith("./") && !spec.startsWith("../")) return null; + if (!isRelativeSpecifier(spec)) return null; const dep = resolveImport(this.program, node.getSourceFile(), spec); if (!dep || dep.fileName.endsWith(".json")) return null; const initName = this.initNameOf.get(dep); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index d6612a074..0e0d22ed2 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -63,11 +63,13 @@ import { fallbackDtsPath, isJsSourceFileName, isNodeTypesPath, + isRelativeSpecifier, JS_ANY_OPERATOR_CODES, JS_RELAXED_TSC_CODES, overridesDtsPath, registerWorkspacePackage, SUPPORTED_NODE_MODULES, + workspacePackageOfPath, } from "./shared.js"; const BASE_OPTIONS: ts.Ts7CompilerOptions = { @@ -883,11 +885,17 @@ function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | nu if (ts.isCallExpression(e) || ts.isNewExpression(e)) { if (!dtsRooted(e.expression) || !ts.isIdentifier(chainRoot7(e.expression))) return false; const args = e.arguments ?? []; + // A CALLABLE argument is admissible when it is itself dts-rooted: + // a builtin-owned function value (vercel's `promisify(fs.readFile)` + // at every cycle member's top level) is runtime-implemented — even + // if the builtin callee invokes it, no user code runs and no + // cluster binding is observable. Function literals and user + // callables keep the refusal. return args.every( (a) => inert(a) && !containsFunctionLike(a) && - checker.getCallSignatures(checker.getTypeAtLocation(a)).length === 0, + (checker.getCallSignatures(checker.getTypeAtLocation(a)).length === 0 || dtsRooted(a)), ); } return false; @@ -1006,7 +1014,7 @@ function resolveNpmImport7( fromFileName: string, specifier: string, ): { packageName: string; version?: string; typesFile: string } | null { - if (specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("node:")) { + if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { return null; } // --provenance-sources: a registered specifier is NOT an npm import — @@ -1106,6 +1114,61 @@ function preflight7(load: LoadResult): { const { program, entry } = load; const diags: ScrDiagnostic[] = [...load.configDiags]; + // Workspace-linked packages register BEFORE the tsc gate. Their files + // live at realpaths OUTSIDE node_modules (the monorepo-tool symlink + // shape), so nothing path-shaped marks them as npm surface — yet their + // shipped JS is the identical foreign-tsconfig story as node_modules JS + // (the island executes it under --dynamic; the author's own build + // checked it, the program's author cannot fix it). The lazy + // registration inside the import-fence walk below runs AFTER errorsOf, + // too late for the gate, so one pass over the program's import edges + // registers them up front: every bare specifier any program file loads — + // import/export declarations, dynamic import("literal") (tsgo chases + // those into the program too; vercel's CLI reaches @vercel/go exactly + // that way), require("literal") — resolved with the own resolver, + // workspace answers recorded. + { + const supportedBuiltins = new Set(SUPPORTED_NODE_MODULES); + const probed = new Set(); + const probe = (fromFile: string, spec: string): void => { + if ( + isRelativeSpecifier(spec) || + spec.startsWith("#") || + spec.startsWith("node:") || + supportedBuiltins.has(spec) + ) { + return; + } + const key = `${dirname(fromFile)} ${spec}`; + if (probed.has(key)) return; + probed.add(key); + const r = resolveBareModule(fromFile, spec); + if (r !== null && r.workspaceDir !== undefined) registerWorkspacePackage(r.packageName, r.workspaceDir); + }; + for (const sf of program.getSourceFiles()) { + if (sf.isDeclarationFile || sf.fileName.endsWith(".json")) continue; + ts.walkPreorder(sf, (n) => { + if ( + (ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) && + n.moduleSpecifier !== undefined && + ts.isStringLiteral(n.moduleSpecifier) + ) { + probe(sf.fileName, n.moduleSpecifier.text); + return "skip"; + } + if (ts.isCallExpression(n)) { + const arg = n.arguments[0]; + const isImportCall = n.expression.kind === ts.SyntaxKind.ImportKeyword; + const isRequireCall = ts.isIdentifier(n.expression) && n.expression.text === "require"; + if ((isImportCall || isRequireCall) && arg !== undefined && ts.isStringLiteralLike(arg)) { + probe(sf.fileName, arg.text); + } + } + return undefined; + }); + } + } + const toPassthrough = (d: ts.Diagnostic): ScrDiagnostic => { const message = ts.flattenDiagnosticMessageText(d, "\n"); // File-less (global/options) diagnostics anchor at the entry with a @@ -1167,8 +1230,20 @@ function preflight7(load: LoadResult): { * every flagless compile (depth 0 keeps node_modules JS out of the * program), and the packages themselves still island — so their checker * errors never gate a build. */ + /* WORKSPACE-LINKED shipped JS is the same story at a different path: the + * package's files realpath OUTSIDE node_modules (so depth-0 exclusion + * never saw them and allowJs+checkJs pulls them straight into the + * program), but they are npm surface all the same — the island executes + * them, the program's author cannot fix them (vercel's @vercel/go ships + * an ncc bundle with dozens of checker errors). Registered up front (the + * pass above), suppressed here unless --npm-static opted them into being + * program modules. */ + const islandJsFile = (file: string): boolean => + isJsSourceFileName(file) && + npmStaticPackageOfPath(file) === null && + (isNodeModulesPath(file) || workspacePackageOfPath(file) !== null); const nodeModulesJsSuppressed = (d: ts.Diagnostic): boolean => - d.fileName !== undefined && isNodeModulesPath(d.fileName) && isJsSourceFileName(d.fileName); + d.fileName !== undefined && islandJsFile(d.fileName); /* JSDoc TYPE positions in JS files are documentation Node never reads: * a name-resolution failure THERE (2304/2552 — prettier's utilities.js * spells a mapped type over a @template name it never declared) types @@ -1246,7 +1321,13 @@ function preflight7(load: LoadResult): { sf.fileName !== ambient && !sf.isDeclarationFile && !sf.fileName.endsWith(".json") && - (!isNodeModulesPath(sf.fileName) || npmStaticPackageOfPath(sf.fileName) !== null), + (!isNodeModulesPath(sf.fileName) || npmStaticPackageOfPath(sf.fileName) !== null) && + // Workspace-linked shipped JS (see islandJsFile): its execution + // home is the island exactly like node_modules JS, so no import + // fences, no module edges, no statement counts from it. Its .ts + // files (a workspace package imported bare AND reached relatively) + // stay program source — only the island-bound JS steps out. + !islandJsFile(sf.fileName), ); const ambientModules = new Set(SUPPORTED_NODE_MODULES); @@ -1339,7 +1420,7 @@ function preflight7(load: LoadResult): { if (stmt.isTypeOnly || erasedTypeOnlyReexport(stmt)) continue; if (!stmt.moduleSpecifier) continue; const fromSpec = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : ""; - if (!fromSpec.startsWith("./") && !fromSpec.startsWith("../")) { + if (!isRelativeSpecifier(fromSpec)) { // NAMED re-exports from a SUPPORTED builtin pass (`export { ok } // from "node:assert"` — prettier's universal/assert facade): the // statement binds nothing locally and evaluates nothing (builtins @@ -1389,7 +1470,7 @@ function preflight7(load: LoadResult): { const specNode: ts.Expression | undefined = stmt.moduleSpecifier; if (specNode === undefined || !ts.isStringLiteral(specNode)) continue; const spec = specNode.text; - const isRelative = spec.startsWith("./") || spec.startsWith("../"); + const isRelative = isRelativeSpecifier(spec); const isBare = !isRelative && !ambientModules.has(spec); // An import edge Node's own resolution refuses BEFORE any module // evaluates: recorded as a startup-crash candidate (the Node-order @@ -1524,17 +1605,27 @@ function preflight7(load: LoadResult): { selfImportTdzFences7(program, sf, clause, diags); } if (clause) { - // Default imports of SUPPORTED builtins in JS sources pass: Node's - // default export of a CJS builtin IS the module object, so the - // binding exposes exactly the namespace-import surface and the - // lowering keys the same tables (builtinNamespaceModuleOf's - // default-import twin). TS sources keep the fence — the checker - // itself refuses the spelling without interop flags, and the - // SC1012 wording beats the raw TS1259. The callable module - // objects (assert, events, test) stay allowed everywhere. + // Default imports of SUPPORTED builtins pass where the spelling is + // legal: Node's default export of a CJS builtin IS the module + // object, so the binding exposes exactly the namespace-import + // surface and the lowering keys the same tables + // (builtinNamespaceModuleOf's default-import twin). JS sources + // always (Node never asks for interop flags); TS sources when the + // adopted interop knobs made the checker accept the spelling + // (vercel's `import os from 'os'` under esModuleInterop — the + // program TYPECHECKED, so the form is the project's own legal + // dialect). A TS project without interop flags keeps the fence: + // the SC1012 wording beats the raw TS1259 at the same site. The + // callable module objects (assert, events, test) stay allowed + // everywhere. + const opts = program.getCompilerOptions() as { + esModuleInterop?: boolean; + allowSyntheticDefaultImports?: boolean; + }; + const interopOn = opts.esModuleInterop === true || opts.allowSyntheticDefaultImports === true; const defaultOk = builtinDefaultImportModule(spec) !== null || - (isJsSourceFileName(sf.fileName) && canonicalBuiltinModule(spec) !== null); + ((isJsSourceFileName(sf.fileName) || interopOn) && canonicalBuiltinModule(spec) !== null); if (clause.name && !isJson && dep === null && !defaultOk) { diags.push(unsupportedDiag("SC1012", locOf7(clause.name))); } @@ -1627,7 +1718,7 @@ function preflight7(load: LoadResult): { continue; } } - const isRelative = req.spec.startsWith("./") || req.spec.startsWith("../"); + const isRelative = isRelativeSpecifier(req.spec); if (!isRelative) { if ( canonicalBuiltinModule(req.spec) === null && @@ -1671,7 +1762,7 @@ function preflight7(load: LoadResult): { for (const call of nestedBareRequiresOf7(sf)) { const spec = requireSpecOf7(call)!; const loc = { file: sf.fileName, start: call.getStart(sf), end: call.getEnd() }; - if (!spec.startsWith("./") && !spec.startsWith("../")) { + if (!isRelativeSpecifier(spec)) { if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) { // Binding-less by construction — same require-site throw // channel as the statement-level form above. @@ -1914,7 +2005,7 @@ function cjsNamedImportLinkCheck( diags: ScrDiagnostic[], ): StartupCrash | null { const lexMemo = new Map>(); - const isRelative = (spec: string): boolean => spec.startsWith("./") || spec.startsWith("../"); + const isRelative = isRelativeSpecifier; // Relative specifiers resolve as ever; PROJECT imports (#alias/self-name // — the same package.json-mediated edges preflight admits) join them so // a named import THROUGH one of a CommonJS module keeps Node's lexer @@ -2168,7 +2259,7 @@ export function orderedImportsOf( if (ts.isImportDeclaration(stmt) && erasedTypeOnlyImport(stmt)) continue; if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; const spec = stmt.moduleSpecifier.text; - const isRelative = spec.startsWith("./") || spec.startsWith("../"); + const isRelative = isRelativeSpecifier(spec); // Relative edges as ever; PROJECT imports (#alias/self-name — the // package.json-mediated specifiers preflight admits as user-module // edges) resolve to the same dep so the importer's %init header calls @@ -2190,7 +2281,7 @@ export function orderedImportsOf( * already classified the import; this is the lookup the module-order and * lowering paths share. */ function npmStaticDepSf7(program: ts.Program, sf: ts.SourceFile, spec: string): ts.SourceFile | null { - if (!npmStaticActive() || spec.startsWith("./") || spec.startsWith("../")) return null; + if (!npmStaticActive() || isRelativeSpecifier(spec)) return null; if (spec.startsWith("node:") || spec.startsWith("#")) return null; const npm = resolveNpmImport7(sf.fileName, spec); if (npm === null || !isNpmStaticPackage(npm.packageName)) return null; diff --git a/packages/compiler/src/frontend/shared.ts b/packages/compiler/src/frontend/shared.ts index 3342e42cb..c6b86e7d2 100644 --- a/packages/compiler/src/frontend/shared.ts +++ b/packages/compiler/src/frontend/shared.ts @@ -118,6 +118,16 @@ export function workspacePackageOfPath(path: string): string | null { return null; } +/** Node's relative-specifier family: './x', '../x', and the bare '.' / + * '..' directory forms (path resolution treats them identically — + * vercel's CLI imports `from '..'` for a parent directory's index). + * Anything else is a package, builtin, or package.json-mediated + * specifier. ('...' and friends are legal PACKAGE names — only the exact + * dot forms are relative.) */ +export function isRelativeSpecifier(spec: string): boolean { + return spec === "." || spec === ".." || spec.startsWith("./") || spec.startsWith("../"); +} + /** Package name from a path under node_modules — the LAST node_modules * segment (nested installs blame the innermost package), scoped-aware: * ".../node_modules/@scope/pkg/dist/x.d.ts" → "@scope/pkg". Paths with no diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 4393cc01f..9184b7581 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -11,7 +11,7 @@ import { checkPreflight, isNodeTypesPath, loadProgram, resolveNpmImport, type Lo import { npmStaticIneligibleReason, npmStaticOffenders, npmStaticPackageOfPath } from "./frontend/npm-static.js"; import { provenanceSources } from "./frontend/provenance-registry.js"; import { resolveBareModule } from "./frontend/resolve.js"; -import { isJsSourceFileName } from "./frontend/shared.js"; +import { isJsSourceFileName, isRelativeSpecifier } from "./frontend/shared.js"; import { lowerToIr, type LowerOptions, type LowerResult } from "./frontend/lowering/lowerer.js"; import type { CoverageInput, NpmStaticStatus } from "./coverage/report.js"; @@ -147,21 +147,26 @@ interface Frontend { * Rejected candidates report their reason so the coverage output says why * auto skipped them. */ function detectAutoPackages(load: LoadResult, statuses: NpmStaticStatus[]): string[] { - const seen = new Map(); // package → typesFile + // package → the resolved types file AND the file whose import found it: + // the runtime-JS probe below must resolve from the SAME importing file, + // or a package visible only to a nested package.json realm (a pnpm + // monorepo's packages/*/node_modules, unreachable from the entry's own + // walk-up) answers "no runtime JS" for perfectly ordinary installs. + const seen = new Map(); for (const sf of [...load.moduleOrder, load.entry]) { if (sf.fileName.includes("/node_modules/")) continue; for (const stmt of sf.statements) { if (!ts7IsImportWithStringSpec(stmt)) continue; const spec = (stmt as { moduleSpecifier: { text: string } }).moduleSpecifier.text; - if (spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("node:") || spec.startsWith("#")) continue; + if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue; const npm = resolveNpmImport(sf.fileName, spec); if (npm === null || isNodeTypesPath(npm.typesFile)) continue; - if (!seen.has(npm.packageName)) seen.set(npm.packageName, npm.typesFile); + if (!seen.has(npm.packageName)) seen.set(npm.packageName, { typesFile: npm.typesFile, fromFile: sf.fileName }); } } const chosen: string[] = []; - for (const [pkg, typesFile] of seen) { - const jsEntry = resolveBareModule(load.entry.fileName, pkg, "js-only"); + for (const [pkg, { typesFile, fromFile }] of seen) { + const jsEntry = resolveBareModule(fromFile, pkg, "js-only"); const reason = npmStaticIneligibleReason( pkg, typesFile, diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index f495a5126..c37c8049d 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -4010,6 +4010,21 @@ ], "diags": [] }, + "/tests/corpus/2390-dot-requires/main.cjs": { + "order": [ + "/tests/corpus/2390-dot-requires/shared.cjs", + "/tests/corpus/2390-dot-requires/sub/index.cjs", + "/tests/corpus/2390-dot-requires/sub/child.cjs", + "/tests/corpus/2390-dot-requires/main.cjs" + ], + "diags": [] + }, + "/tests/corpus/2391-default-builtin-imports/main.ts": { + "order": [ + "/tests/corpus/2391-default-builtin-imports/main.ts" + ], + "diags": [] + }, "/tests/corpus/240-stdout-write.ts": { "order": [ "/tests/corpus/240-stdout-write.ts" @@ -5829,6 +5844,48 @@ ], "diags": [] }, + "/tests/fixtures/node-types/argv-env.ts": { + "order": [ + "/tests/fixtures/node-types/argv-env.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/fenced.ts": { + "order": [ + "/tests/fixtures/node-types/fenced.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/import-fences.ts": { + "order": [ + "/tests/fixtures/node-types/import-fences.ts" + ], + "diags": [ + { + "code": "SC1010", + "message": "the 'v8' module is not supported yet", + "loc": { + "file": "/tests/fixtures/node-types/import-fences.ts", + "start": 422, + "end": 461 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), installed npm packages (their code runs under --dynamic), and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, util, util/types, string_decoder, readline, events, stream, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, and node:test modules (bare or node:-prefixed) are supported" + } + ] + }, + "/tests/fixtures/node-types/path-os.ts": { + "order": [ + "/tests/fixtures/node-types/path-os.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/stream-capture.ts": { + "order": [ + "/tests/fixtures/node-types/stream-capture.ts" + ], + "diags": [] + }, "/tests/fixtures/npm/cases/abandoned-handles/main.ts": { "order": [ "/tests/fixtures/npm/cases/abandoned-handles/main.ts" @@ -6100,6 +6157,12 @@ ], "diags": [] }, + "/tests/fixtures/npm/cases/workspace-unclean/main.ts": { + "order": [ + "/tests/fixtures/npm/cases/workspace-unclean/main.ts" + ], + "diags": [] + }, "/tests/fixtures/npm/cases/zlib-shims/main.ts": { "order": [ "/tests/fixtures/npm/cases/zlib-shims/main.ts" @@ -6166,48 +6229,6 @@ "hint": "set \"strictNullChecks\": true (or \"strict\": true) in the project tsconfig" } ] - }, - "/tests/fixtures/node-types/argv-env.ts": { - "order": [ - "/tests/fixtures/node-types/argv-env.ts" - ], - "diags": [] - }, - "/tests/fixtures/node-types/fenced.ts": { - "order": [ - "/tests/fixtures/node-types/fenced.ts" - ], - "diags": [] - }, - "/tests/fixtures/node-types/import-fences.ts": { - "order": [ - "/tests/fixtures/node-types/import-fences.ts" - ], - "diags": [ - { - "code": "SC1010", - "message": "the 'v8' module is not supported yet", - "loc": { - "file": "/tests/fixtures/node-types/import-fences.ts", - "start": 422, - "end": 461 - }, - "milestone": "M4", - "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), installed npm packages (their code runs under --dynamic), and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, util, util/types, string_decoder, readline, events, stream, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, and node:test modules (bare or node:-prefixed) are supported" - } - ] - }, - "/tests/fixtures/node-types/path-os.ts": { - "order": [ - "/tests/fixtures/node-types/path-os.ts" - ], - "diags": [] - }, - "/tests/fixtures/node-types/stream-capture.ts": { - "order": [ - "/tests/fixtures/node-types/stream-capture.ts" - ], - "diags": [] } } } diff --git a/packages/compiler/test/ts7/resolver-parity.test.ts b/packages/compiler/test/ts7/resolver-parity.test.ts index 4cf4d3351..c03d2af6c 100644 --- a/packages/compiler/test/ts7/resolver-parity.test.ts +++ b/packages/compiler/test/ts7/resolver-parity.test.ts @@ -20,6 +20,7 @@ import { resolveRelativeModule, resolveTypeDirective, } from "../../src/frontend/resolve.js"; +import { isRelativeSpecifier } from "../../src/frontend/shared.js"; import { options5 } from "./harness.js"; const repoRoot = join(import.meta.dirname, "../../../.."); @@ -96,7 +97,9 @@ test("relative and bare specifiers across the whole test tree resolve identicall for (const file of files) { for (const spec of specifiersOf(file)) { const reference = ts5.resolveModuleName(spec, file, OPTS, ts5.sys).resolvedModule ?? null; - if (spec.startsWith("./") || spec.startsWith("../")) { + // Bare '.' and '..' are the relative directory forms (SEMANTICS 362) + // — ts5 resolves them relative too, so they ride the relative arm. + if (isRelativeSpecifier(spec)) { relative++; const ours = resolveRelativeModule(file, spec); // The ONE deliberate relative-resolution delta: a PROJECT diff --git a/tests/corpus/2390-dot-requires/main.cjs b/tests/corpus/2390-dot-requires/main.cjs new file mode 100644 index 000000000..40c9a1518 --- /dev/null +++ b/tests/corpus/2390-dot-requires/main.cjs @@ -0,0 +1,7 @@ +// The bare '.' and '..' relative-specifier forms (Node's CJS resolution +// treats them exactly like './' and '../' directory imports: the target +// directory's package.json "main", else its index file) — vercel's CLI +// spells `from '..'` at 41 sites, and the CJS require twin is the form +// Node itself runs, so this case pins both lanes byte-for-byte. +const child = require("./sub/child.cjs"); +console.log(child.report()); diff --git a/tests/corpus/2390-dot-requires/package.json b/tests/corpus/2390-dot-requires/package.json new file mode 100644 index 000000000..d463d4ee7 --- /dev/null +++ b/tests/corpus/2390-dot-requires/package.json @@ -0,0 +1,4 @@ +{ + "type": "commonjs", + "main": "./shared.cjs" +} diff --git a/tests/corpus/2390-dot-requires/shared.cjs b/tests/corpus/2390-dot-requires/shared.cjs new file mode 100644 index 000000000..78c2fa68c --- /dev/null +++ b/tests/corpus/2390-dot-requires/shared.cjs @@ -0,0 +1,3 @@ +// What `require('..')` answers from inside sub/: this directory's +// package.json "main" target. +module.exports = { tag: "parent-main" }; diff --git a/tests/corpus/2390-dot-requires/sub/child.cjs b/tests/corpus/2390-dot-requires/sub/child.cjs new file mode 100644 index 000000000..bfd176c3d --- /dev/null +++ b/tests/corpus/2390-dot-requires/sub/child.cjs @@ -0,0 +1,5 @@ +const parent = require(".."); // the case directory -> package.json main -> shared.cjs +const self = require("."); // this directory -> sub/package.json main -> index.cjs +module.exports.report = function () { + return parent.tag + ":" + self.name; +}; diff --git a/tests/corpus/2390-dot-requires/sub/index.cjs b/tests/corpus/2390-dot-requires/sub/index.cjs new file mode 100644 index 000000000..71e9f7508 --- /dev/null +++ b/tests/corpus/2390-dot-requires/sub/index.cjs @@ -0,0 +1 @@ +module.exports = { name: "sub-index" }; diff --git a/tests/corpus/2390-dot-requires/sub/package.json b/tests/corpus/2390-dot-requires/sub/package.json new file mode 100644 index 000000000..dc6f3f4f8 --- /dev/null +++ b/tests/corpus/2390-dot-requires/sub/package.json @@ -0,0 +1,4 @@ +{ + "type": "commonjs", + "main": "./index.cjs" +} diff --git a/tests/corpus/2391-default-builtin-imports/main.ts b/tests/corpus/2391-default-builtin-imports/main.ts new file mode 100644 index 000000000..292a2c9e4 --- /dev/null +++ b/tests/corpus/2391-default-builtin-imports/main.ts @@ -0,0 +1,15 @@ +// DEFAULT imports of supported builtins in a TS source, under the +// project's own adopted interop knob (esModuleInterop in this case's +// tsconfig): Node's default export of a CJS builtin IS the module object, +// so the binding exposes exactly the namespace-import surface — the same +// tables `import * as fs` keys (vercel's CLI spells `import fs from +// 'node:fs'` and `import path from 'path'` throughout). Without the +// interop knob the SC1012 fence stands; with it, the checker accepted the +// spelling and so does the lowering. +import fs from "node:fs"; +import path from "path"; +import url from "node:url"; + +console.log(fs.existsSync(process.cwd()) ? "cwd-exists" : "cwd-missing"); +console.log(path.join("a", "b")); +console.log(url.fileURLToPath("file:///tmp/x.txt")); diff --git a/tests/corpus/2391-default-builtin-imports/tsconfig.json b/tests/corpus/2391-default-builtin-imports/tsconfig.json new file mode 100644 index 000000000..75cf960b2 --- /dev/null +++ b/tests/corpus/2391-default-builtin-imports/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true + } +} diff --git a/tests/coverage-fixtures/cycle-inert-builtin/a.ts b/tests/coverage-fixtures/cycle-inert-builtin/a.ts new file mode 100644 index 000000000..4492da20b --- /dev/null +++ b/tests/coverage-fixtures/cycle-inert-builtin/a.ts @@ -0,0 +1,18 @@ +// One member of a benign import cycle whose top level calls a BUILTIN +// with a BUILTIN function value as the argument (vercel's link.ts spells +// `const readFile = promisify(fs.readFile)` at the top of a 5-cycle +// cluster; Object.freeze(JSON.parse) is the same shape on scriptc's own +// declared surface). No user code can run during the init window — the +// callee and the callable argument are both dts-rooted — so the cycle +// admission must hold and the module graph must load (the statement +// itself may still be a statement-level blocker; that is a different +// fence than SC1016). +import { helper } from "./b"; + +const frozenParse = Object.freeze(JSON.parse); + +export const A = "a"; + +export function go(n: number): string { + return helper(n) + ":" + typeof frozenParse; +} diff --git a/tests/coverage-fixtures/cycle-inert-builtin/b.ts b/tests/coverage-fixtures/cycle-inert-builtin/b.ts new file mode 100644 index 000000000..afcfca2a3 --- /dev/null +++ b/tests/coverage-fixtures/cycle-inert-builtin/b.ts @@ -0,0 +1,7 @@ +// Closes the cycle with a NAMED import read only from a function body +// (the deferred position the admission demands). +import { A } from "./a"; + +export function helper(n: number): string { + return A + n; +} diff --git a/tests/coverage-fixtures/cycle-inert-builtin/main.ts b/tests/coverage-fixtures/cycle-inert-builtin/main.ts new file mode 100644 index 000000000..f2cb2f56b --- /dev/null +++ b/tests/coverage-fixtures/cycle-inert-builtin/main.ts @@ -0,0 +1,3 @@ +import { go } from "./a"; + +console.log(go(3)); diff --git a/tests/coverage-fixtures/dot-parent/index.ts b/tests/coverage-fixtures/dot-parent/index.ts new file mode 100644 index 000000000..dba86cb6d --- /dev/null +++ b/tests/coverage-fixtures/dot-parent/index.ts @@ -0,0 +1,2 @@ +// What `from ".."` answers inside lib/: this directory's index module. +export const banner = "parent-banner"; diff --git a/tests/coverage-fixtures/dot-parent/lib/child.ts b/tests/coverage-fixtures/dot-parent/lib/child.ts new file mode 100644 index 000000000..7eb9d1091 --- /dev/null +++ b/tests/coverage-fixtures/dot-parent/lib/child.ts @@ -0,0 +1,11 @@ +// The bare '.' and '..' relative-specifier forms in the TS project +// dialect (vercel's CLI: `from '..'` at 41 sites): '..' is the parent +// DIRECTORY's index module, '.' this directory's own — exactly what the +// project's bundler-resolution checker answers, so preflight and the +// lowering resolve the same edges. (Node running raw TS would refuse a +// directory import in ESM; the compiled program is the tsc dialect — +// SEMANTICS.md's relative-specifier note.) +import { banner } from ".."; +import { local } from "."; + +export const title = banner + ":" + local + "!"; diff --git a/tests/coverage-fixtures/dot-parent/lib/index.ts b/tests/coverage-fixtures/dot-parent/lib/index.ts new file mode 100644 index 000000000..16cc02613 --- /dev/null +++ b/tests/coverage-fixtures/dot-parent/lib/index.ts @@ -0,0 +1 @@ +export const local = "lib-index"; diff --git a/tests/coverage-fixtures/dot-parent/main.ts b/tests/coverage-fixtures/dot-parent/main.ts new file mode 100644 index 000000000..3926147e9 --- /dev/null +++ b/tests/coverage-fixtures/dot-parent/main.ts @@ -0,0 +1,3 @@ +import { title } from "./lib/child"; + +console.log(title); diff --git a/tests/fixtures/npm-static/nested/inner/app.ts b/tests/fixtures/npm-static/nested/inner/app.ts new file mode 100644 index 000000000..f3cb33f52 --- /dev/null +++ b/tests/fixtures/npm-static/nested/inner/app.ts @@ -0,0 +1,5 @@ +import shouty from "shouty"; + +export function run(): string { + return shouty("quiet words"); +} diff --git a/tests/fixtures/npm-static/nested/inner/node_modules/shouty/index.d.ts b/tests/fixtures/npm-static/nested/inner/node_modules/shouty/index.d.ts new file mode 100644 index 000000000..01daa28e7 --- /dev/null +++ b/tests/fixtures/npm-static/nested/inner/node_modules/shouty/index.d.ts @@ -0,0 +1 @@ +export default function shouty(text: string): string; diff --git a/tests/fixtures/npm-static/nested/inner/node_modules/shouty/index.js b/tests/fixtures/npm-static/nested/inner/node_modules/shouty/index.js new file mode 100644 index 000000000..5e647d64d --- /dev/null +++ b/tests/fixtures/npm-static/nested/inner/node_modules/shouty/index.js @@ -0,0 +1,5 @@ +export default function shouty(text) { + let out = ""; + for (const ch of text) out += ch === " " ? "_" : ch.toUpperCase(); + return out + "!"; +} diff --git a/tests/fixtures/npm-static/nested/inner/node_modules/shouty/package.json b/tests/fixtures/npm-static/nested/inner/node_modules/shouty/package.json new file mode 100644 index 000000000..075fc3a27 --- /dev/null +++ b/tests/fixtures/npm-static/nested/inner/node_modules/shouty/package.json @@ -0,0 +1,6 @@ +{ + "name": "shouty", + "version": "1.0.0", + "type": "module", + "exports": "./index.js" +} diff --git a/tests/fixtures/npm-static/nested/main.ts b/tests/fixtures/npm-static/nested/main.ts new file mode 100644 index 000000000..07f91f1a9 --- /dev/null +++ b/tests/fixtures/npm-static/nested/main.ts @@ -0,0 +1,10 @@ +// The auto probe's resolution ANCHOR: shouty is installed only in +// inner/'s own node_modules — visible from the importing file's walk-up, +// invisible from this entry's (the pnpm-monorepo shape: vercel's CLI deps +// live in packages/cli/node_modules while the driver entry sits outside). +// auto must probe the runtime JS from the file that IMPORTS the package, +// or eligibility answers "no runtime JS entry resolves" for a perfectly +// ordinary install. +import { run } from "./inner/app.ts"; + +console.log(run()); diff --git a/tests/fixtures/npm/cases/workspace-unclean/main.ts b/tests/fixtures/npm/cases/workspace-unclean/main.ts new file mode 100644 index 000000000..ea79c1abf --- /dev/null +++ b/tests/fixtures/npm/cases/workspace-unclean/main.ts @@ -0,0 +1,10 @@ +// A workspace-linked UNTYPED package (main → shipped CJS, no .d.ts, the +// @vercel/go shape): its realpath escapes node_modules, so allowJs pulls +// the shipped JS into the checker's program — and the checker refuses +// shapes Node runs fine. Those errors are the foreign-tsconfig story +// (third-party shipped JS the program's author cannot fix); the package's +// execution home is the island, so they never gate, and this program +// must build AND agree with Node. +import { describe } from "wsunclean"; + +console.log(describe(21)); diff --git a/tests/fixtures/npm/cases/workspace-unclean/node_modules/wsunclean b/tests/fixtures/npm/cases/workspace-unclean/node_modules/wsunclean new file mode 120000 index 000000000..b42711e9b --- /dev/null +++ b/tests/fixtures/npm/cases/workspace-unclean/node_modules/wsunclean @@ -0,0 +1 @@ +../../../workspace/wsunclean \ No newline at end of file diff --git a/tests/fixtures/npm/workspace/wsunclean/dist/index.js b/tests/fixtures/npm/workspace/wsunclean/dist/index.js new file mode 100644 index 000000000..7028a38a3 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsunclean/dist/index.js @@ -0,0 +1,22 @@ +"use strict"; +// The bundled-dist diet: shapes tsc's checkJs REFUSES but Node runs +// happily (@vercel/go's ncc bundle carries dozens of each — a function +// binding reassigned, an executor-less-looking resolve(), an empty if +// body). The package ships NO declarations and its realpath lies OUTSIDE +// node_modules (the workspace link), so the checker's program pulls this +// file in and finds the errors — which must never gate: the island is +// this file's one execution home. +function greet(name) { + return "hi " + name; +} +greet = function (name) { + return "hello " + name; +}; +if (process.env.WSUNCLEAN_NEVER_SET) ; +var ready = new Promise(function (resolve) { + resolve(); +}); +exports.describe = function (n) { + return "wsunclean:" + greet("go") + ":" + n * 2; +}; +exports.ready = ready; diff --git a/tests/fixtures/npm/workspace/wsunclean/package.json b/tests/fixtures/npm/workspace/wsunclean/package.json new file mode 100644 index 000000000..bd79dda17 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsunclean/package.json @@ -0,0 +1,5 @@ +{ + "name": "wsunclean", + "version": "1.0.0", + "main": "./dist/index" +} diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 190afd2ed..1046b7cf2 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -127,6 +127,26 @@ test("type errors render the same code frame a build prints", () => { expect(out).toContain("^"); }); +test("bare '.' and '..' imports are relative edges (the vercel-CLI dialect)", () => { + // `from '..'` / `from '.'` resolve as directory imports to the target's + // index module — exactly what the project's bundler-resolution checker + // answered — so the graph loads and everything compiles statically. + const out = report(fixture("dot-parent/main.ts")); + expect(out).toContain("(100%)"); + expect(out).toContain("fully static"); +}); + +test("a cycle whose top level calls a builtin with a builtin function value is admitted", () => { + // vercel's link.ts cluster: `promisify(fs.readFile)` at a cycle member's + // top level. Callee and callable argument are both dts-rooted — no user + // code can run in the init window — so no SC1016 and the analysis + // renders statement counts (the statement itself may stay a + // statement-LEVEL blocker; that is a different fence). + const out = report(fixture("cycle-inert-builtin/main.ts")); + expect(out).not.toContain("circular imports"); + expect(out).toContain("statements analyzed"); +}); + test("every corpus program is 100% static (corpus and coverage agree)", async () => { // The differential corpus compiles by definition; coverage must agree. // `// @dynamic` programs compile under --dynamic, so analyze them that way. diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index b3dc36613..4d30ac438 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -50,15 +50,15 @@ async function runBinary(cmd: string, args: string[]): Promise { /** Compile one pilot statically (no --dynamic — the whole point) with the * named packages opted in; cache-keyed over the program and the vendored * packages. */ -async function buildStatic(entry: string, npmStatic: string[]): Promise { +async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise { const hash = createHash("sha256"); const inputs = [ entry, - ...globSync(join(pilotRoot, "node_modules/**/*.{js,mjs,cjs,json,d.ts}")).sort(), + ...globSync(join(pilotRoot, "**/node_modules/**/*.{js,mjs,cjs,json,d.ts}")).sort(), ]; for (const f of inputs) hash.update(f).update(readFileSync(f)); const key = hash - .update(npmStatic.join(",")) + .update(npmStatic === "auto" ? "auto" : npmStatic.join(",")) .update(sanitize ? "san" : "plain") .digest("hex") .slice(0, 16); @@ -119,6 +119,26 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(coverage.stats.statementsFailed).toBe(0); }, 120_000); + // Auto's runtime-JS probe anchors at the IMPORTING file, not the entry: + // shouty is installed only in inner/'s node_modules (the pnpm-monorepo + // shape — vercel's CLI deps live in packages/cli/node_modules while the + // analysis driver sits outside every package realm), so an entry-anchored + // probe answers "no runtime JS entry resolves" for an ordinary install. + test("--npm-static=auto probes runtime JS from the importing file's realm", async () => { + const entry = join(pilotRoot, "nested/main.ts"); + const { coverage } = analyze(entry, { npmStatic: "auto" }); + expect(coverage.npmStatic).toEqual([{ package: "shouty", status: "static" }]); + expect(coverage.preflightFailed).toBe(false); + expect(coverage.stats.statementsFailed).toBe(0); + const binary = await buildStatic(entry, "auto"); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 120_000); + // Auto refuses ms: it ships no own .d.ts (the declared-claim criterion), // so the import keeps today's story — and the explicit opt-in below is // the user's override. diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index b53acd34c..16077a95c 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -194,3 +194,20 @@ test("no-types-node: a bare project outside the repo compiles on the fallback de const { stdout } = await execFileAsync(result.binaryPath); expect(stdout).toBe("hi bare\n"); }); + +test("dot-parent: bare '.' and '..' imports build and run (the TS project dialect)", async () => { + // The vercel-CLI spelling (`from '..'` for a parent directory's index): + // scriptc-only — Node refuses directory imports when running raw TS as + // ESM, but the compiled program follows the project's own bundler- + // resolution dialect (the SEMANTICS.md relative-specifier note). + const outDir = outDirFor("dot-parent"); + const result = await compile(join(repoRoot, "tests/coverage-fixtures/dot-parent/main.ts"), { + outPath: join(outDir, "main"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const { stdout } = await execFileAsync(result.binaryPath); + expect(stdout).toBe("parent-banner:lib-index!\n"); +});