mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-25 06:24:27 +08:00
Vercel CLI source ladder: five frontend fixes from the typecheck gate
- Bare '.' and '..' specifiers resolve as relative directory imports - TS default imports of supported builtins pass under the project's interop knobs - Workspace packages register eagerly before the tsc gate; their shipped JS never gates - Benign-cycle admission accepts d.ts-rooted callable arguments in the init window - npm-static auto probes from the importing file's realm; ladder fixtures renumbered 2390/2391
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>(SUPPORTED_NODE_MODULES);
|
||||
const probed = new Set<string>();
|
||||
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<string>(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<ts.SourceFile, Set<string>>();
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>(); // 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<string, { typesFile: string; fromFile: string }>();
|
||||
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,
|
||||
|
||||
@@ -4010,6 +4010,21 @@
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/2390-dot-requires/main.cjs": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/2390-dot-requires/shared.cjs",
|
||||
"<repo>/tests/corpus/2390-dot-requires/sub/index.cjs",
|
||||
"<repo>/tests/corpus/2390-dot-requires/sub/child.cjs",
|
||||
"<repo>/tests/corpus/2390-dot-requires/main.cjs"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/2391-default-builtin-imports/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/2391-default-builtin-imports/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/240-stdout-write.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/240-stdout-write.ts"
|
||||
@@ -5829,6 +5844,48 @@
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/argv-env.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/argv-env.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/fenced.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/fenced.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/import-fences.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/import-fences.ts"
|
||||
],
|
||||
"diags": [
|
||||
{
|
||||
"code": "SC1010",
|
||||
"message": "the 'v8' module is not supported yet",
|
||||
"loc": {
|
||||
"file": "<repo>/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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/path-os.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/path-os.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/stream-capture.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/stream-capture.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/npm/cases/abandoned-handles/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/npm/cases/abandoned-handles/main.ts"
|
||||
@@ -6100,6 +6157,12 @@
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/npm/cases/workspace-unclean/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/npm/cases/workspace-unclean/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/npm/cases/zlib-shims/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/npm/cases/zlib-shims/main.ts"
|
||||
@@ -6166,48 +6229,6 @@
|
||||
"hint": "set \"strictNullChecks\": true (or \"strict\": true) in the project tsconfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/argv-env.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/argv-env.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/fenced.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/fenced.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/import-fences.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/import-fences.ts"
|
||||
],
|
||||
"diags": [
|
||||
{
|
||||
"code": "SC1010",
|
||||
"message": "the 'v8' module is not supported yet",
|
||||
"loc": {
|
||||
"file": "<repo>/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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/path-os.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/path-os.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/fixtures/node-types/stream-capture.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/node-types/stream-capture.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "commonjs",
|
||||
"main": "./shared.cjs"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// What `require('..')` answers from inside sub/: this directory's
|
||||
// package.json "main" target.
|
||||
module.exports = { tag: "parent-main" };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { name: "sub-index" };
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "commonjs",
|
||||
"main": "./index.cjs"
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { go } from "./a";
|
||||
|
||||
console.log(go(3));
|
||||
@@ -0,0 +1,2 @@
|
||||
// What `from ".."` answers inside lib/: this directory's index module.
|
||||
export const banner = "parent-banner";
|
||||
@@ -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 + "!";
|
||||
@@ -0,0 +1 @@
|
||||
export const local = "lib-index";
|
||||
@@ -0,0 +1,3 @@
|
||||
import { title } from "./lib/child";
|
||||
|
||||
console.log(title);
|
||||
@@ -0,0 +1,5 @@
|
||||
import shouty from "shouty";
|
||||
|
||||
export function run(): string {
|
||||
return shouty("quiet words");
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export default function shouty(text: string): string;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export default function shouty(text) {
|
||||
let out = "";
|
||||
for (const ch of text) out += ch === " " ? "_" : ch.toUpperCase();
|
||||
return out + "!";
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "shouty",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./index.js"
|
||||
}
|
||||
+10
@@ -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());
|
||||
@@ -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));
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../workspace/wsunclean
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "wsunclean",
|
||||
"version": "1.0.0",
|
||||
"main": "./dist/index"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -50,15 +50,15 @@ async function runBinary(cmd: string, args: string[]): Promise<RunResult> {
|
||||
/** 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<string> {
|
||||
async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise<string> {
|
||||
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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user