diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 404648051e..29a34660d6 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -165,6 +165,10 @@ export type CompileFailure = { export interface CompileWarnings { warnings?: ScrDiagnostic[]; sourceTexts?: Map; + /** The Node islands the build ran: the npm packages and the program + * modules (absolute file paths). Reported again on an executable cache + * hit, so a runner can tell an island result from a native one. */ + islands?: { packages: string[]; modules: string[] }; } export type CompileSourceResult = diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index a5f4fdbe2c..a1b2f071ff 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -453,6 +453,22 @@ export function nodeIslandNoteDiag( }; } +/** SC8001 — a program module runs in a Node island (a note, once per island + * module in the build output; DESIGN.md note 1 §3.2). */ +export function nodeIslandModuleNoteDiag( + file: string, + klass: string, + reason: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8001", + message: `module '${file}' runs in a Node island: ${klass}: ${reason}`, + loc, + hint: "sloppy-mode code Node runs; --node-islands=off compiles this module natively instead", + }; +} + /** SC8010 — a value crosses an island boundary in a way NI-4 cannot keep * exact yet (callbacks, native objects, remote promises). */ export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic { diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 604af05b9c..491cc5fa25 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -147,6 +147,8 @@ interface EarlyExecutableCacheStamp { native: EarlyExecutableNativeFeatures; /** The build's performance warnings (SC61xx), which a cache hit repeats. */ performance?: ScrDiagnostic[]; + /** The Node islands the build ran, which a cache hit reports again. */ + islands?: { packages: string[]; modules: string[] }; /** Programs that call into Node.js: the libnode pack identity the build * recorded (EarlyExecutableCacheOptions.libnodeIdentity). */ libnode?: string; @@ -259,6 +261,8 @@ export interface EarlyExecutableCacheHit { frontend: FrontendInputSnapshot; /** The performance warnings the build reported (SC61xx), repeated on a hit. */ performance?: ScrDiagnostic[]; + /** The Node islands the build ran, repeated on a hit. */ + islands?: { packages: string[]; modules: string[] }; /** How a --profile-use build's profile matched, reported again on a hit. */ profileMatch?: NativeProfileMatch; } @@ -267,6 +271,16 @@ export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit { nativeDependencies?: NativeArtifactDependency[]; } +function isIslands(value: unknown): value is { packages: string[]; modules: string[] } { + if (typeof value !== "object" || value === null) return false; + const islands = value as Record; + return ( + Array.isArray(islands.packages) && + Array.isArray(islands.modules) && + [...islands.packages, ...islands.modules].every((item) => typeof item === "string") + ); +} + function isProfileMatch(value: unknown): value is NativeProfileMatch { if (typeof value !== "object" || value === null) return false; const match = value as Record; @@ -745,6 +759,7 @@ async function readExecutableCache( ...(Array.isArray(stamp.performance) && stamp.performance.length > 0 ? { performance: stamp.performance } : {}), + ...(isIslands(stamp.islands) ? { islands: stamp.islands } : {}), ...(isProfileMatch(stamp.profileMatch) ? { profileMatch: stamp.profileMatch } : {}), ...(ir === null ? {} : { irPath: paths.irPath }), ...(llvmSourcePath === undefined ? {} : { llvmSourcePath }), @@ -936,6 +951,7 @@ export async function publishEarlyExecutableCache( ...(result.performance === undefined || result.performance.length === 0 ? {} : { performance: result.performance }), + ...(result.islands === undefined ? {} : { islands: result.islands }), ...(result.native.nodeIsland === true ? { libnode: options.libnodeIdentity?.() ?? "" } : {}), ...(result.profileMatch === undefined ? {} : { profileMatch: result.profileMatch }), }; diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index a85e17f370..fc6484438d 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -1,8 +1,10 @@ +import { realpathSync } from "node:fs"; import type { CompileFailure, CompileRequestOptions } from "../compile-types.js"; import type { FfiProfile } from "../ffi/ffi-manifest.js"; import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, + nodeIslandModuleNoteDiag, nodeIslandNoteDiag, nodeIslandRetryFailedDiag, npmStaticRefusalDiag, @@ -24,15 +26,20 @@ import type { FrontendFactory } from "../frontend/pipeline.js"; import type { UnitStore } from "../frontend/lowering/unit-reuse.js"; import type { CompilationTiming } from "../timing.js"; import { readProjectConfig } from "../project-config.js"; -import { nodeIslandPolicy } from "../node-islands/policy.js"; +import { + isMarkedUserModule, + nodeIslandPolicy, + type NodeIslandPolicy, +} from "../node-islands/policy.js"; import { buildNodeIslandReport } from "../node-islands/report.js"; -import type { IslandPackage, NodeIslandSet } from "../node-islands/boundary.js"; +import type { IslandModule, IslandPackage, NodeIslandSet } from "../node-islands/boundary.js"; import { classifyDiagnostic, isIslandReasonCode } from "../node-islands/classify.js"; import { packageDirOf, packageVersionOf } from "../node-islands/origin.js"; import { describe } from "../node-islands/summary.js"; import { resolveBareModule } from "../frontend/resolve.js"; import { isEsmFile } from "../frontend/lowering/lower-node-island.js"; -import type { PackageEdge } from "../node-islands/scan.js"; +import type { NodeIslandScan, PackageEdge } from "../node-islands/scan.js"; +import { isJsSourceFileName } from "../frontend/tsc-codes.js"; export interface PreparedExecutableModule { ok: true; @@ -42,6 +49,10 @@ export interface PreparedExecutableModule { /** Divergence warnings (SC6xxx): the build succeeds; these sites can * behave differently from Node. */ warnings: ScrDiagnostic[]; + /** The Node islands the build ran (the SC8001 notes name them with their + * reasons): packages by name, modules by file. A cached build repeats + * them. */ + islands?: { packages: string[]; modules: string[] }; } /** Release the parser/checker before native object generation begins. @@ -57,6 +68,7 @@ export function prepareExecutableModule( lowerIslands: ReadonlyMap = new Map(), retryFences = true, unitStore: UnitStore | null = null, + lowerModules: ReadonlyMap = new Map(), ): PreparedExecutableModule | CompileFailure { // The island decision reads the detector's scan unless islands are off. const islandScan = @@ -66,11 +78,14 @@ export function prepareExecutableModule( // Node islands (scope-B NI-4): packages refused natively run in Node, // unless the policy says otherwise. The frontend reloads without them, // so their declarations type the program. - const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands); + const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands, lowerModules); if (islandDecision !== null) { fe.dispose(); fe = createFrontend(entryPath, opts.npmStatic, undefined, undefined, { islandPackages: new Set(islandDecision.packages.keys()), + ...(islandDecision.modules.size > 0 + ? { islandModules: new Set(islandDecision.modules.keys()) } + : {}), islandScan, }); timing("frontend-island-reload"); @@ -121,6 +136,7 @@ export function prepareExecutableModule( grown, retryFences, unitStore, + lowerModules, ); } return fail(fe.preflight); @@ -159,7 +175,11 @@ export function prepareExecutableModule( lowerIslands, fe.nodeIslandScan?.({ userJs: false }).edges, ); - if (grown !== null) { + // Sloppy user modules the lowering refused join the same retry + // (DESIGN.md note 1 §3.2): one reload with every island at once, + // never one round per module. + const grownModules = loweringModules(entryPath, opts, lowered.diagnostics, lowerModules); + if (grown !== null || grownModules !== null) { fe.dispose(); frontendDisposed(); disposed = true; @@ -170,9 +190,10 @@ export function prepareExecutableModule( buildPlatform, createFrontend, timing, - grown, + grown ?? lowerIslands, retryFences, unitStore, + grownModules ?? lowerModules, ); } // A failed program whose compiled packages need the runtime or a @@ -198,6 +219,8 @@ export function prepareExecutableModule( timing, marked, false, + undefined, + lowerModules, ); } return fail(lowered.diagnostics); @@ -258,6 +281,14 @@ export function prepareExecutableModule( ...(lowered.divergences ?? []), ...(lowered.performance ?? []), ], + ...(islandDecision === null + ? {} + : { + islands: { + packages: [...islandDecision.packages.keys()].sort(), + modules: [...islandDecision.modules.keys()].sort(), + }, + }), }; // Packages compiled with runtime fences that need the JavaScript // runtime or a mutable object model (fenceIslands): build again with @@ -284,6 +315,7 @@ export function prepareExecutableModule( islands, false, unitStore, + lowerModules, ); if (islanded.ok) return islanded; firstError ??= islanded.diagnostics[0] ?? null; @@ -298,16 +330,63 @@ export function prepareExecutableModule( return prepared; } -/** The packages a build runs in Node islands, or null for none: under - * `--node-islands=auto` (the default) every npm package the native - * pipeline refused, plus packages marked `node`; under `explicit` only the - * marked ones; under `off`, none (refusals stay SC2013). Each island is - * reported once as an SC8001 note. */ +/** Whether two spellings name the same file (the checker resolves through + * symlinks; a CLI argument may not). */ +function sameFile(a: string, b: string): boolean { + if (samePath(a, b)) return true; + try { + const real = realpathSync(a); + const other = realpathSync(b); + return samePath(real, other); + } catch { + return false; + } +} + +function samePath(a: string, b: string): boolean { + return a.replaceAll("\\", "/") === b.replaceAll("\\", "/"); +} + +/** The program modules that run in Node islands, by file: modules marked + * with `--node-island=` or `scriptc.nodeIslands.modules`. The entry + * stays native: a program whose entry is an island runs the whole file in + * Node, which is a different feature. */ +function moduleIslands( + entryPath: string, + policy: NodeIslandPolicy, + scan: NodeIslandScan, +): Map { + const modules = new Map(); + for (const m of scan.modules) { + if (m.origin !== "user") continue; + if (!isJsSourceFileName(m.file)) continue; + if (sameFile(m.file, entryPath)) continue; + const marked = isMarkedUserModule(policy, m.file); + if (marked === null) continue; + modules.set(m.file, { + file: m.file, + class: "marked", + reason: + marked === "cli" + ? "marked with --node-island" + : 'marked "node" in scriptc.nodeIslands.modules', + decision: "marked", + }); + } + return modules; +} + +/** The packages and modules a build runs in Node islands, or null for none: + * under `--node-islands=auto` (the default) every npm package the native + * pipeline refused, plus the packages and modules marked `node`; under + * `explicit` only the marked ones; under `off`, none (refusals stay + * SC2013). Each island is reported once as an SC8001 note. */ function decideNodeIslands( entryPath: string, opts: CompileRequestOptions, fe: ReturnType, lowerIslands: ReadonlyMap, + lowerModules: ReadonlyMap, ): (NodeIslandSet & { notes: ScrDiagnostic[] }) | null { const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); if (policy.mode === "off" || fe.nodeIslandScan === undefined) return null; @@ -321,10 +400,15 @@ function decideNodeIslands( : [], ); for (const name of lowerIslands.keys()) refused.add(name); - if (refused.size === 0 && policy.forcedNode.size === 0) return null; + const scan = fe.nodeIslandScan({ userJs: false }); + const modules = moduleIslands(entryPath, policy, scan); + // Modules the lowering refused keep their first reason (the reload no + // longer compiles them). + for (const [file, island] of lowerModules) if (!modules.has(file)) modules.set(file, island); + if (refused.size === 0 && policy.forcedNode.size === 0 && modules.size === 0) return null; const report = buildNodeIslandReport({ entry: entryPath, - scan: fe.nodeIslandScan({ userJs: false }), + scan, npm: fe.npmStatic, diagnostics: [], fences: [], @@ -397,7 +481,17 @@ function decideNodeIslands( ), ); } - return packages.size === 0 ? null : { packages, notes }; + for (const island of [...modules.values()].sort((a, b) => + a.file < b.file ? -1 : a.file > b.file ? 1 : 0, + )) + notes.push( + nodeIslandModuleNoteDiag(island.file, island.class, island.reason, { + file: island.file, + start: 0, + end: 0, + }), + ); + return packages.size === 0 && modules.size === 0 ? null : { packages, modules, notes }; } /** The island set grown by the npm packages whose own modules refused to @@ -478,6 +572,41 @@ function loweringIslands( return grown.size === known.size ? null : grown; } +/** The island modules grown by the lowering's refusals in user JavaScript + * (`--node-islands=auto` only), or null when there are none: a sloppy + * module whose semantics the native pipeline refuses (an implicit global, + * a mapped `arguments`, a `with` the pre-scan could not decide) runs in + * Node. One retry adds every one of them at once. */ +function loweringModules( + entryPath: string, + opts: CompileRequestOptions, + diagnostics: readonly ScrDiagnostic[], + known: ReadonlyMap, +): Map | null { + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + if (policy.mode !== "auto" || known.size > 16) return null; + const grown = new Map(known); + for (const d of diagnostics) { + if (!isIslandReasonCode(d.code)) continue; + const file = d.loc.file; + // Packages run in Node through their own decision (loweringIslands). + if (file.includes("/node_modules/") || packageDirOf(file) !== null) continue; + if (!isJsSourceFileName(file) || sameFile(file, entryPath)) continue; + if (grown.has(file)) continue; + const reason = classifyDiagnostic(d, "refusal"); + // A gap is a construct scriptc does not implement, not a sloppy + // semantic: running it in Node would hide a compiler limitation. + if (reason.class === "gap") continue; + grown.set(file, { + file, + class: reason.class, + reason: describe(reason), + decision: "auto", + }); + } + return grown.size === known.size ? null : grown; +} + /** Preflight refusals at import statements of natively compiled npm * packages (`--node-islands=auto` only): those packages become islands. */ function preflightIslands( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce580..6091b8ce4e 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -665,6 +665,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { // graph: resolveImport follows its node_modules link to the loaded // TypeScript entry. Its internal `.js` spellings have already been // resolved to `.ts` by that graph. + // A relative import of a program module the pre-scan put in a Node + // island (DESIGN.md note 1 §3.2): the island loads at this module's + // position and its bindings are island values. + if (lowerer.nodeIslands !== null && collectIslandImport(lowerer, fp.sf, stmt, spec)) continue; const projectDep = isRelativeSpecifier(spec) ? null : resolveImport(lowerer.program, fp.sf, spec); diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index dc49bb99fb..742b47d4bb 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -16,11 +16,12 @@ * checked against the declared result type, like `JSON.parse(s) as T`; a * thrown Error crosses as its nearest intrinsic class with its message and * string `code`. */ -import { readFileSync, realpathSync } from "node:fs"; +import { readFileSync, realpathSync, statSync } from "node:fs"; import { resolveFrom } from "../../node-islands/resolve.js"; -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import * as ts from "../ts7/adapter.js"; import { isJsSourceFile, locOf } from "../program.js"; +import { isRelativeSpecifier } from "../workspace-registry.js"; import type { NodeApiMember } from "../node-api-import.js"; import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js"; import { @@ -49,7 +50,7 @@ import { isForeignType, isIslandSubclassDecl, markIslandSubclass, - islandPackageOfFile, + islandOwnerOfFile, type NodeIslandSet, } from "../../node-islands/boundary.js"; import { stripTypeWrappers } from "./typed-arguments.js"; @@ -288,21 +289,54 @@ export function isEsmFile(file: string): boolean { } } +/** The file a relative (or absolute) specifier names from `fromFile`, as + * Node's CommonJS/ESM resolution would find it, or null. Extension fallback + * and directory indexes only: an island module is a file the program + * already loaded, and `exports` maps stay Node's job. */ +function relativeIslandFile(fromFile: string, spec: string): string | null { + const base = isAbsolute(spec) ? spec : resolve(dirname(fromFile), spec); + const candidates = [base]; + if (!/\.[cm]?js$/.test(base)) { + for (const ext of [".js", ".cjs", ".mjs"]) candidates.push(base + ext); + } + for (const ext of [".js", ".cjs", ".mjs"]) candidates.push(join(base, `index${ext}`)); + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + /* the next candidate */ + } + } + return null; +} + /** The island module file `spec` names from `fromFile`, or null when the - * specifier does not resolve into an island package. */ + * specifier does not resolve into an island package or module. */ export function islandModuleFile( islands: NodeIslandSet, fromFile: string, spec: string, kind: "import" | "require", ): string | null { - const js = resolveBareModule(fromFile, spec, "js-only", kind); - if (js === null) return null; - if (islandPackageOfFile(islands, js.typesFile) === null) return null; + let file: string; + if (isRelativeSpecifier(spec) || isAbsolute(spec)) { + // A program module the pre-scan put in a Node island (DESIGN.md note 1 + // §3.2): the file itself is the island, resolved as Node would. + if (islands.modules.size === 0) return null; + const resolved = relativeIslandFile(fromFile, spec); + if (resolved === null) return null; + if (islandOwnerOfFile(islands, resolved) === null) return null; + file = resolved; + } else { + const js = resolveBareModule(fromFile, spec, "js-only", kind); + if (js === null) return null; + if (islandOwnerOfFile(islands, js.typesFile) === null) return null; + file = js.typesFile; + } try { - return realpathSync(js.typesFile); + return realpathSync(file); } catch { - return js.typesFile; + return file; } } diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index ea0cfb702e..ecae178946 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -415,6 +415,10 @@ export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] { } for (const island of lowerer.nodeIslands?.packages.values() ?? []) packageJsFiles(island.dir, queue); + // A program module the build runs in a Node island is loaded by Node, so + // the program modules IT imports are shared with it like an island + // package's (DESIGN.md note 1 §3.2). + for (const module of lowerer.nodeIslands?.modules.values() ?? []) queue.push(real(module.file)); if (queue.length === 0 && candidates.size === 0) return plans; const seen = new Set(); // Classes island code extends, by the compiled module that exports them. diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index ab3e5cc834..cbd1fb2387 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -146,6 +146,10 @@ export interface FrontendOptions { /** Packages that run in Node islands (scope-B NI-4): never compiled, * typed by their declarations. */ islandPackages?: ReadonlySet; + /** Program modules that run in Node islands (DESIGN.md note 1 §3.2): + * their bodies are never compiled; their imports lower to island loads. + * Absolute file paths. */ + islandModules?: ReadonlySet; /** The Node island report (coverage): the project's scriptc settings and * the detector's scan of the program and its npm packages. Builds do not * read either. */ @@ -565,6 +569,11 @@ export function runFrontend( } } +/** A file path as the frontend and the island decision spell it. */ +function normalizeFile(file: string): string { + return file.replaceAll("\\", "/"); +} + /** Rounds of dropping single refused overlay annotations before the * fallback drops whole files' overlays. */ const OVERLAY_ANNOTATION_ROUNDS = 4; @@ -586,6 +595,18 @@ function loadFrontend( beginShapeOverlayBuild(); const statuses: NpmStaticStatus[] = []; const npmSites = new Map(); + // Program modules that run in Node islands (DESIGN.md note 1 §3.2): their + // bodies are not lowered, so their sloppy semantics stay Node's, and a + // diagnostic anchored in one does not fail the program the build does not + // compile. + const islandModules = + options.islandModules === undefined || options.islandModules.size === 0 + ? null + : new Set([...options.islandModules].map(normalizeFile)); + const inIslandModule = (loc: SrcLoc | undefined): boolean => + islandModules !== null && loc !== undefined && islandModules.has(normalizeFile(loc.file)); + const withoutIslandModules = (diags: ScrDiagnostic[]): ScrDiagnostic[] => + islandModules === null ? diags : diags.filter((d) => !inIslandModule(d.loc)); // Node island detection (the report, or a build that may run islands): // how each package was refused, and the modules of packages removed from // the program. @@ -848,8 +869,9 @@ function loadFrontend( const patchDiagnostics = typePatchDiagnostics()?.diagnostics ?? []; const buildWarnings = [...(report ? [] : configDiagnostics), ...patchDiagnostics]; return { - preflight: + preflight: withoutIslandModules( report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight, + ), ...(buildWarnings.length > 0 ? { warnings: buildWarnings } : {}), entryText: () => finalLoad.entry.text, entryExports: () => entryFunctionExports(finalLoad.entry), @@ -896,9 +918,22 @@ function loadFrontend( return texts; }, lower: (opts, valid) => { + // The module order without the island modules, computed once per load: + // a reload keeps the array the lowering appends its dynamic import + // modules to. + const islandFiltered = new WeakMap(); + const lowerOrder = (l: typeof finalLoad): SourceFile[] => { + if (islandModules === null) return l.moduleOrder; + let kept = islandFiltered.get(l.moduleOrder); + if (kept === undefined) { + kept = l.moduleOrder.filter((sf) => !islandModules.has(normalizeFile(sf.fileName))); + islandFiltered.set(l.moduleOrder, kept); + } + return kept; + }; const lowerLoad = (l: typeof finalLoad): LowerResult => { if (l.shapeOverlayState !== undefined) restoreShapeOverlayState(l.shapeOverlayState); - return lowerToIr(l.program, l.entry, l.moduleOrder, { + return lowerToIr(l.program, l.entry, lowerOrder(l), { ...opts, frontendServices: l.services, startupCrash: l.startupCrash ?? null, @@ -964,7 +999,7 @@ function loadFrontend( const t1 = performance.now(); // The preflight fills the module order; the tsc world reads the // files as written, so it answers as the first load did. - const reloadPreflight = checkPreflight(finalLoad); + const reloadPreflight = withoutIslandModules(checkPreflight(finalLoad)); if (reloadPreflight.length > 0) return { ...lowered, module: null, diagnostics: reloadPreflight }; const t2 = performance.now(); @@ -1066,7 +1101,7 @@ function loadFrontend( setShapeOverlayDropped(true); finalLoad.dispose(); finalLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes }); - const reloadPreflight = checkPreflight(finalLoad); + const reloadPreflight = withoutIslandModules(checkPreflight(finalLoad)); if (reloadPreflight.length > 0) return { ...lowered, module: null, diagnostics: reloadPreflight }; lowered = lowerOnce(); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 34ea20b28d..927b56e687 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -539,10 +539,11 @@ export async function compile( } /** Divergence warnings collected while a compile lowers (none when a cache - * hit skips lowering). */ + * hit skips lowering), and the Node islands the build ran. */ interface CompileWarningSink { warnings: ScrDiagnostic[]; sourceTexts: Map | null; + islands?: { packages: string[]; modules: string[] }; } /** The package.json texts type patch warnings point into, re-read for a @@ -560,6 +561,15 @@ function cachedWarningSources(warnings: readonly ScrDiagnostic[]): Map( result: T, sink: CompileWarningSink, ): T { - if (!result.ok || sink.warnings.length === 0) return result; - return { ...result, warnings: sink.warnings, sourceTexts: sink.sourceTexts ?? new Map() }; + if (!result.ok) return result; + const islands = sink.islands === undefined ? {} : { islands: sink.islands }; + if (sink.warnings.length === 0) return { ...result, ...islands }; + return { + ...result, + warnings: sink.warnings, + sourceTexts: sink.sourceTexts ?? new Map(), + ...islands, + }; } /** Build-time API compatibility fence: a caller's existing CompileOptions @@ -1026,6 +1043,9 @@ interface PreparedExecutable { nativeFeatures: EarlyExecutableNativeFeatures; programSplit: ReturnType; sourceTexts: Map; + /** The Node islands the build ran, reported with its result (a runner + * reads them to tell an island result from a native one). */ + islands?: { packages: string[]; modules: string[] }; } /** Finish frontend work in a separate scope so the AST, checker caches and @@ -1103,6 +1123,8 @@ async function prepareExecutableInput( warningSink.warnings = prepared.warnings; warningSink.sourceTexts = sourceTexts; } + if (warningSink !== undefined && prepared.islands !== undefined) + warningSink.islands = prepared.islands; const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); const defaultSourcePaths = { @@ -1262,6 +1284,7 @@ async function prepareExecutableInput( nativeFeatures, programSplit, sourceTexts, + ...(prepared.islands === undefined ? {} : { islands: prepared.islands }), }; } @@ -1447,6 +1470,7 @@ type FrontendProcessResult = nativeFeatures: EarlyExecutableNativeFeatures; sourceTexts: [string, string][]; warnings: ScrDiagnostic[]; + islands?: { packages: string[]; modules: string[] }; frontend: FrontendInputSnapshot; } | { @@ -1507,6 +1531,8 @@ async function prepareExecutableInputInChild( warningSink.warnings = result.warnings; warningSink.sourceTexts = sourceTexts; } + if (warningSink !== undefined && result.kind === "prepared" && result.islands !== undefined) + warningSink.islands = result.islands; if (result.kind === "diagnostics") return { ok: false, diagnostics: result.diagnostics, sourceTexts }; return { @@ -1518,6 +1544,7 @@ async function prepareExecutableInputInChild( nativeFeatures: result.nativeFeatures, programSplit: null, sourceTexts, + ...(result.islands === undefined ? {} : { islands: result.islands }), }; } @@ -1595,6 +1622,7 @@ export async function runFrontendProcess(requestPath: string, resultPath: string nativeFeatures: prepared.nativeFeatures, sourceTexts: [...prepared.sourceTexts], warnings: warnings.warnings, + ...(prepared.islands === undefined ? {} : { islands: prepared.islands }), frontend, }; } else if (!prepared.ok) { @@ -2007,6 +2035,8 @@ async function compileTracked( warningSink.warnings = earlyHit.performance; warningSink.sourceTexts = cachedWarningSources(earlyHit.performance); } + if (warningSink !== undefined && earlyHit.islands !== undefined) + warningSink.islands = earlyHit.islands; const executableCacheOptions = earlyCacheOptions; // Route/proof metadata is independently evictable. A full-compiler // fallback that still finds the validated payload repairs that lightweight @@ -2253,6 +2283,7 @@ async function compileTracked( frontend: frontendInputs.snapshot(), ...(irSourcePath === undefined ? {} : { irPath: irSourcePath }), ...performanceWarnings(warningSink), + ...islandFields(warningSink), ...profileMatchField(nativeProgramObject), }); publishedExecutable = true; @@ -2275,6 +2306,7 @@ async function compileTracked( frontend: frontendInputs.snapshot(), ...(irSourcePath === undefined ? {} : { irPath: irSourcePath }), ...performanceWarnings(warningSink), + ...islandFields(warningSink), ...profileMatchField(nativeProgramObject), }).catch(() => undefined); } diff --git a/packages/compiler/src/node-islands/boundary.ts b/packages/compiler/src/node-islands/boundary.ts index be43393912..bd317e23fa 100644 --- a/packages/compiler/src/node-islands/boundary.ts +++ b/packages/compiler/src/node-islands/boundary.ts @@ -28,9 +28,45 @@ export interface IslandPackage { reason: string; } -/** The program's island packages, by package name. */ +/** One program module the build runs in a Node island (DESIGN.md + * note 1 §3.2): a user JavaScript file whose semantics need the JavaScript + * engine, keyed by its absolute path. */ +export interface IslandModule { + /** The module file (absolute, as the checker spells it). */ + file: string; + /** The island class (runtime, object-model, gap, packaging) and the first + * reason, for the SC8001 note. */ + class: string; + reason: string; + /** Marked by the user (`--node-island`, `nodeIslands.modules`) or + * detected by the pre-scan under `--node-islands=auto`. */ + decision: "marked" | "auto"; +} + +/** The program's island packages, by package name, and island modules, by + * absolute file path. */ export interface NodeIslandSet { packages: ReadonlyMap; + modules: ReadonlyMap; +} + +function normalizePath(file: string): string { + return file.replaceAll("\\", "/"); +} + +/** The island module at `file`, or null. */ +export function islandModuleOfFile(islands: NodeIslandSet, file: string): IslandModule | null { + if (islands.modules.size === 0) return null; + return islands.modules.get(file) ?? islands.modules.get(normalizePath(file)) ?? null; +} + +/** The island (package or module) that owns a declaration or JavaScript + * file, or null. Modules first: a user module is never inside a package. */ +export function islandOwnerOfFile( + islands: NodeIslandSet, + file: string, +): IslandPackage | IslandModule | null { + return islandModuleOfFile(islands, file) ?? islandPackageOfFile(islands, file); } /** The island package that owns a declaration or JavaScript file, or null. @@ -65,7 +101,7 @@ function declaredInIsland(islands: NodeIslandSet, symbol: ts.Symbol | null | und if (decls.length === 0) return false; if (islandSubclassDecls.size > 0 && decls.some((d) => islandSubclassDecls.has(d))) return true; return decls.every( - (d) => islandPackageOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null, + (d) => islandOwnerOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null, ); } @@ -150,7 +186,8 @@ export function crossingOf( let active: NodeIslandSet | null = null; export function setActiveNodeIslands(islands: NodeIslandSet | null): void { - active = islands !== null && islands.packages.size > 0 ? islands : null; + active = + islands !== null && (islands.packages.size > 0 || islands.modules.size > 0) ? islands : null; } export function activeNodeIslands(): NodeIslandSet | null { diff --git a/packages/compiler/src/node-islands/policy.ts b/packages/compiler/src/node-islands/policy.ts index 72dbf3cd04..945bdfd220 100644 --- a/packages/compiler/src/node-islands/policy.ts +++ b/packages/compiler/src/node-islands/policy.ts @@ -4,9 +4,8 @@ * become islands only when marked (`nodeIslands.modules`, * `--node-island=`); `"native"` (`--native=`) makes one * dependency strict; `--node-islands=off` makes the whole program strict. - * - * This version is report-only: the policy decides what WOULD run in Node, - * and coverage reports it, but code generation does not change. */ + * The decision drives code generation: an island package or module is not + * compiled natively but loaded by the program's Node island. */ import { isAbsolute, join, resolve, dirname } from "node:path"; import type { ProjectConfig } from "../project-config.js";