Report which packages and modules would run in a Node island (#1691)

This commit is contained in:
Chris Tate
2026-10-25 04:32:22 -05:00
committed by GitHub
63 changed files with 4193 additions and 105 deletions
+1
View File
@@ -9,6 +9,7 @@ node_modules/
!tests/corpus/*/node_modules/
!tests/fixtures/npm-static/node_modules/
!tests/coverage-fixtures/wasm/node_modules/
!tests/coverage-fixtures/node-islands*/node_modules/
!tests/coverage-fixtures/dynamic-tier/package/node_modules/
!tests/fixtures/npm-static/nested/inner/node_modules/
!tests/fixtures/npm-static/node_modules/*/node_modules/
+21 -2
View File
@@ -72,7 +72,9 @@ async function tryFastPath(): Promise<number | null> {
values.sparse ||
// The full compiler resolves `native` against the build host.
values["target-cpu"] !== undefined ||
(values["external-types"] ?? []).length > 0
(values["external-types"] ?? []).length > 0 ||
// The full CLI validates the Node island policy flags.
hasNodeIslandFlags(values)
)
return null;
const backend = values.backend;
@@ -182,6 +184,21 @@ async function tryFastPath(): Promise<number | null> {
});
}
function hasNodeIslandFlags(
values: ReturnType<
typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }>
>["values"],
): boolean {
return (
values["node-islands"] !== undefined ||
(values["node-island"] ?? []).length > 0 ||
(values.native ?? []).length > 0 ||
values.node !== undefined ||
values["explain-island"] !== undefined ||
values["strict-user-js"]
);
}
/** An unchanged program's coverage verdict replays from the verdict cache
* without loading the compiler graph. Anything else (a miss, a comment-only
* edit, options the replay does not cover) defers to the full CLI. */
@@ -212,7 +229,9 @@ async function coverageFastPath(
values["provenance-sources"] ||
values["emit-ir"] ||
(values["npm-static"] ?? []).length > 0 ||
(values["external-types"] ?? []).length > 0
(values["external-types"] ?? []).length > 0 ||
// The replay covers the default Node island policy only.
hasNodeIslandFlags(values)
)
return null;
let startup: typeof import("@scriptc/compiler/startup-cache");
+91 -12
View File
@@ -4,15 +4,22 @@ import { parseArgs } from "node:util";
import { isExactExternalTypeSpecifier } from "../frontend/program.js";
import { renderDiagnostics } from "../diagnostics/render.js";
import {
COVERAGE_FAIL_ON,
coverageEnvelope,
coveragePasses,
renderCoverage,
renderExplainDynamic,
renderExplainIsland,
type CoverageFailOn,
} from "../coverage/report.js";
import {
NODE_ISLANDS_MODES,
type NodeIslandOptions,
type NodeIslandsMode,
} from "../node-islands/policy.js";
import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js";
import { buildEnvelope } from "../diagnostics/envelope.js";
import { renderWarnings } from "../diagnostics/render.js";
import { renderConfigWarnings, renderWarnings } from "../diagnostics/render.js";
import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
import { isNativeOptimization } from "../backend/optimization.js";
import {
@@ -114,6 +121,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["emit-ir"] ||
values.tier !== undefined ||
values["explain-dynamic"] !== undefined ||
hasNodeIslandFlags(values) ||
!values["keep-llvm"]
) {
fail(
@@ -172,6 +180,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["emit-ir"] ||
values.tier !== undefined ||
values["explain-dynamic"] !== undefined ||
hasNodeIslandFlags(values) ||
!values["keep-llvm"];
if (values.out === undefined || inputs.length === 0 || others) {
fail(
@@ -225,7 +234,8 @@ async function main(args: string[], host: CliHost): Promise<number> {
(values["npm-static"] ?? []).length > 0 ||
externalTypeArgs.length > 0 ||
values.tier !== undefined ||
values["explain-dynamic"] !== undefined
values["explain-dynamic"] !== undefined ||
hasNodeIslandFlags(values)
) {
fail(
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--target-cpu/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
@@ -298,14 +308,9 @@ async function main(args: string[], host: CliHost): Promise<number> {
fail(`--print=diagnostics is a build/coverage option\n\n${USAGE}`);
}
const failOnRaw = values["fail-on"];
if (
failOnRaw !== undefined &&
failOnRaw !== "blockers" &&
failOnRaw !== "divergences" &&
failOnRaw !== "dynamic"
) {
if (failOnRaw !== undefined && !(COVERAGE_FAIL_ON as readonly string[]).includes(failOnRaw)) {
fail(
`unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences, dynamic)\n\n${USAGE}`,
`unknown --fail-on value "${failOnRaw}" (supported: ${COVERAGE_FAIL_ON.join(", ")})\n\n${USAGE}`,
);
}
// --no-dynamic-tier-warning leaves out SC6101 (diagnostics/dynamic-tier.ts).
@@ -326,6 +331,48 @@ async function main(args: string[], host: CliHost): Promise<number> {
if (explainDynamic !== undefined && command !== "coverage") {
fail(`--explain-dynamic is a coverage option\n\n${USAGE}`);
}
const explainIsland = values["explain-island"];
if (explainIsland !== undefined && command !== "coverage") {
fail(`--explain-island is a coverage option\n\n${USAGE}`);
}
if (explainIsland === "") fail(`--explain-island needs a package name\n\n${USAGE}`);
if (values["strict-user-js"] && command !== "coverage") {
fail(`--strict-user-js is a coverage option\n\n${USAGE}`);
}
// The Node island policy's command-line half. Report-only: build and run
// validate it, coverage reports the islands it would choose.
const islandMode = values["node-islands"];
if (islandMode !== undefined && !(NODE_ISLANDS_MODES as readonly string[]).includes(islandMode)) {
fail(
`unknown --node-islands value "${islandMode}" (supported: ${NODE_ISLANDS_MODES.join(", ")})\n\n${USAGE}`,
);
}
const listValues = (raw: string[] | undefined): string[] =>
(raw ?? [])
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v) => v !== "");
const islandArgs = listValues(values["node-island"]);
const nativeArgs = listValues(values.native);
for (const name of nativeArgs) {
if (islandArgs.includes(name))
fail(`--native=${name} and --node-island=${name} contradict each other\n\n${USAGE}`);
}
if (values.node === "") fail(`--node needs the path of a node executable\n\n${USAGE}`);
const nodeIslands: NodeIslandOptions | undefined =
islandMode === undefined &&
islandArgs.length === 0 &&
nativeArgs.length === 0 &&
values.node === undefined &&
!values["strict-user-js"]
? undefined
: {
...(islandMode !== undefined ? { mode: islandMode as NodeIslandsMode } : {}),
...(islandArgs.length > 0 ? { islands: islandArgs } : {}),
...(nativeArgs.length > 0 ? { native: nativeArgs } : {}),
...(values.node !== undefined ? { node: resolve(values.node) } : {}),
...(values["strict-user-js"] ? { strictUserJs: true } : {}),
};
if (printNativeLinkInfo && command !== "build") {
fail(`--print=native-link-info is a build option\n\n${USAGE}`);
}
@@ -468,6 +515,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
...(tier !== undefined ? { tier } : {}),
...(explainDynamic !== undefined ? { explainDynamic: true } : {}),
...(wasmFiles.length > 0 ? { wasm: wasmFiles } : {}),
...(nodeIslands !== undefined ? { nodeIslands } : {}),
});
if (!dynamicTierWarning) delete coverage.performance;
if (printDiagnostics) {
@@ -483,6 +531,17 @@ async function main(args: string[], host: CliHost): Promise<number> {
process.stdout.write(
renderCoverage(coverage, { color, sourceTexts, root: process.cwd() }) + "\n",
);
if (explainIsland !== undefined) {
process.stdout.write(
"\n" +
renderExplainIsland(coverage.nodeIslands, explainIsland, {
color,
sourceTexts,
root: process.cwd(),
}) +
"\n",
);
}
if (explainDynamic !== undefined && coverage.tiers !== undefined) {
process.stdout.write(
"\n" +
@@ -577,14 +636,21 @@ async function main(args: string[], host: CliHost): Promise<number> {
// warning names likely-hot functions that use untyped values. `run`
// keeps stderr for the program's own output.
const color = process.stderr.isTTY ?? false;
const divergences = warnings.filter((d) => !isPerformanceCode(d.code));
const config = warnings.filter((d) => d.code === "SC8004");
const divergences = warnings.filter((d) => !isPerformanceCode(d.code) && d.code !== "SC8004");
const performance = warnings.filter((d) => isPerformanceCode(d.code));
if (divergences.length > 0)
if (config.length > 0)
process.stderr.write(
renderConfigWarnings(config, result.sourceTexts ?? new Map(), { color }) + "\n",
);
if (divergences.length > 0) {
if (config.length > 0) process.stderr.write("\n");
process.stderr.write(
renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n",
);
}
if (performance.length > 0) {
if (divergences.length > 0) process.stderr.write("\n");
if (divergences.length > 0 || config.length > 0) process.stderr.write("\n");
process.stderr.write(renderPerformanceWarnings(performance) + "\n");
}
}
@@ -615,6 +681,19 @@ async function main(args: string[], host: CliHost): Promise<number> {
return 0;
}
/** Whether any Node island flag is present (they belong to build, run and
* coverage). */
function hasNodeIslandFlags(values: ReturnType<typeof parseCli>["values"]): boolean {
return (
values["node-islands"] !== undefined ||
(values["node-island"] ?? []).length > 0 ||
(values.native ?? []).length > 0 ||
values.node !== undefined ||
values["explain-island"] !== undefined ||
values["strict-user-js"]
);
}
/** Both installed and seed commands use this argument and diagnostic contract. */
export async function runCli(args: string[], host: CliHost): Promise<number> {
try {
+33 -3
View File
@@ -31,11 +31,13 @@ Options:
(native-link-info implies --emit=obj and never links;
diagnostics prints the versioned JSON diagnostics
envelope for build and coverage)
--fail-on <blockers|divergences|dynamic>
--fail-on <blockers|divergences|dynamic|node-islands|eager-node-islands|island-gaps>
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
divergences, or also when it has likely-hot functions
that use untyped values (SC6101)
divergences, likely-hot functions that use untyped
values (SC6101), any Node island (eager-node-islands is
the same until deferred evaluation lands), or a gap
island (a package scriptc should compile but cannot yet)
--backend <b> code generator (llvm)
--optimization <release|dev|speed>
native optimization posture (default: release/-O2). dev
@@ -110,6 +112,28 @@ Options:
--no-dynamic-tier-warning
build and coverage: leave out SC6101, the warning that
names likely-hot functions that use untyped values
--node-islands <auto|explicit|off>
Node island policy (default: auto): npm packages outside
the native subset run in Node automatically; explicit
allows only marked islands; off allows none. REPORT-ONLY
in this version: coverage reports the islands a build
would choose; builds compile as before
--node-island <pkg-or-path>
mark an npm package or one of your modules (a path or
glob) to run in a Node island (repeatable, comma lists;
report-only)
--native <pkg> require an npm package to compile natively; outside the
native subset it is SC8003 (repeatable; report-only)
--node <path> the node executable Node islands use (report-only)
--explain-island <pkg>
coverage only: why a package would run in a Node island,
with the first reason per module, the native import
sites and the packages inside its closure
--strict-user-js
coverage only: report constructor functions, prototype
and shape mutation, sloppy-mode semantics and builtin
patching in your own .js/.cjs/.mjs modules as
SC8101–SC8105 blockers
--wasm <file.wasm>
coverage only: report a WebAssembly module the program
loads whose path its sources do not spell (repeatable;
@@ -144,6 +168,12 @@ export const CLI_OPTIONS = {
"explain-dynamic": { type: "string" },
"dynamic-tier-warning": { type: "boolean", default: true },
wasm: { type: "string", multiple: true },
"node-islands": { type: "string" },
"node-island": { type: "string", multiple: true },
native: { type: "string", multiple: true },
node: { type: "string" },
"explain-island": { type: "string" },
"strict-user-js": { type: "boolean", default: false },
lib: { type: "boolean", default: false },
profile: { type: "string" },
help: { type: "boolean", short: "h", default: false },
+4
View File
@@ -6,6 +6,7 @@ import type { NativeProfileMatch } from "./backend/native-profile.js";
import type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
import type { CoverageInput } from "./coverage/report.js";
import type { TierMode } from "./frontend/tier-analysis.js";
import type { NodeIslandOptions } from "./node-islands/policy.js";
export type CompileOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe";
@@ -193,6 +194,9 @@ export interface AnalyzeOptions {
/** `--wasm <file>`: WebAssembly module files the program loads that its
* sources do not name (absolute paths); reported with the discovered ones. */
wasm?: readonly string[];
/** The Node island policy's command-line half (report-only: coverage
* reports which packages and modules would run in a Node island). */
nodeIslands?: NodeIslandOptions;
}
export interface AnalyzeResult {
+281 -3
View File
@@ -17,6 +17,7 @@ import {
type DiagnosticsEnvelope,
type LocatedDiagnostic,
type SourcePosition,
type NodeIslandsEnvelope,
type TierUnitPosition,
type TiersEnvelope,
} from "../diagnostics/envelope.js";
@@ -25,6 +26,14 @@ import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { ProvenanceSources } from "../frontend/provenance-registry.js";
import type { DemotionUnit, TierReport } from "../frontend/tier-analysis.js";
import type { WasmModuleReport } from "../wasm/discover.js";
import {
describe as describeReason,
hasNodeIslandDecisions,
type NodeIslandEntry,
type NodeIslandReport,
} from "../node-islands/summary.js";
import { classDescription } from "../node-islands/rules.js";
import type { SrcLoc } from "../ir/ir.js";
export interface CoverageInput {
file: string;
@@ -65,6 +74,10 @@ export interface CoverageInput {
/** The WebAssembly modules the build can see (wasm/discover.ts): their
* size, validity and the proposals they use. */
wasmModules?: WasmModuleReport[];
/** Node islands, report-only: the packages and user modules that would
* run in a Node island, and why. Present when there is something to
* report. */
nodeIslands?: NodeIslandReport;
/** True when tsc preflight failed — no coverage can be computed. */
preflightFailed: boolean;
}
@@ -148,6 +161,19 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push(` ${c(RED, "not analyzable")}: ${config[0]!.message}`);
return out.join("\n");
}
const projectConfig = input.diagnostics.filter((d) => d.code === "SC8004");
if (projectConfig.length > 0) {
out.push(
` ${c(RED, "not analyzable")}: the project's scriptc configuration has ${projectConfig.length} unknown setting${projectConfig.length === 1 ? "" : "s"}`,
);
for (const d of projectConfig) {
const p = locator.position(d.loc.file, d.loc.start);
out.push(` ${c(DIM, "SC8004")} ${d.message}`);
out.push(` ${c(DIM, `at ${where({ file: d.loc.file, ...p })}`)}`);
if (d.hint !== undefined) out.push(` ${c(DIM, `hint: ${d.hint}`)}`);
}
return out.join("\n");
}
const tsc = input.diagnostics.filter((d) => d.code === "SC0001");
if (tsc.length > 0) {
const world = tsc.filter((d) => d.typeWorld === true).length;
@@ -225,6 +251,22 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push("");
}
// Node islands (report-only): what would run in Node, gap islands
// first (DESIGN.md §1.3: a gap island is compiler work an island hides).
if (input.nodeIslands !== undefined && hasNodeIslandDecisions(input.nodeIslands)) {
renderNodeIslands(
input.nodeIslands,
out,
c,
(loc) => {
const p = locator.position(loc.file, loc.start);
return where({ file: loc.file, ...p });
},
opts.root,
);
out.push("");
}
// The WebAssembly modules the build can see (wasm/discover.ts). A build
// translates the valid ones to native code (wasm/translate.ts) when the
// program uses the WebAssembly API; the others fail to load at run time.
@@ -477,9 +519,24 @@ function displayPath(file: string, root: string | undefined): string {
}
/** What `coverage --fail-on` checks: reached blockers (they fail a build)
* and, optionally, divergences or the performance warning. Unreached
* blockers never fail. */
export type CoverageFailOn = "blockers" | "divergences" | "dynamic";
* and, optionally, divergences, the performance warning or Node islands.
* Unreached blockers never fail. */
export type CoverageFailOn =
| "blockers"
| "divergences"
| "dynamic"
| "node-islands"
| "eager-node-islands"
| "island-gaps";
export const COVERAGE_FAIL_ON: readonly CoverageFailOn[] = [
"blockers",
"divergences",
"dynamic",
"node-islands",
"eager-node-islands",
"island-gaps",
];
/** Whether the analysis passes the selected gate. Preflight failures
* always fail; `failOn` undefined means only preflight fails (the report's
@@ -492,6 +549,16 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b
if (input.diagnostics.length > 0) return false;
if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false;
// The Node island gates (report-only islands count: they are what a
// build would choose). Every island evaluates eagerly until deferred
// evaluation lands, so eager-node-islands equals node-islands for now.
// Would-be policy errors (SC8002, SC8003) fail every island gate.
const islands = input.nodeIslands;
if (islands === undefined) return true;
const anyIsland = failOn === "node-islands" || failOn === "eager-node-islands";
if ((anyIsland || failOn === "island-gaps") && islands.violations.length > 0) return false;
if (anyIsland && (islands.islands.length > 0 || islands.userModules.length > 0)) return false;
if (failOn === "island-gaps" && islands.islands.some((i) => i.class === "gap")) return false;
return true;
}
@@ -555,6 +622,9 @@ export function coverageEnvelope(
}),
...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}),
...(input.wasmModules !== undefined ? { wasmModules: input.wasmModules } : {}),
...(input.nodeIslands !== undefined
? { nodeIslands: nodeIslandsEnvelope(input.nodeIslands, locator) }
: {}),
groups,
diagnostics: located,
};
@@ -692,3 +762,211 @@ export function renderExplainDynamic(
}
return out.join("\n");
}
/* ── Node islands (report-only) ───────────────────────────────────────── */
const ISLAND_REASONS_SHOWN = 1;
function islandName(i: { package: string; version: string | null }): string {
return i.version === null ? i.package : `${i.package}@${i.version}`;
}
function renderNodeIslands(
report: NodeIslandReport,
out: string[],
c: (code: string, s: string) => string,
at: (loc: SrcLoc) => string,
root: string | undefined,
): void {
const n = report.islands.length + report.userModules.length;
out.push(
` ${c(BOLD, "Node islands")} ${c(DIM, `(would-be, report-only: the build is unchanged; --node-islands=${report.policy})`)}`,
);
if (n === 0) out.push(` ${c(DIM, "none")}`);
const widest = Math.max(0, ...report.islands.map((i) => i.class.length));
for (const island of report.islands) {
const tone = island.class === "gap" ? RED : YELLOW;
const decided =
island.decision === "auto"
? ""
: c(
DIM,
` (marked by ${island.decision === "cli" ? "--node-island" : "scriptc.packages"})`,
);
out.push(` ${c(tone, island.class.padEnd(widest))} ${islandName(island)}${decided}`);
for (const r of island.reasons.slice(0, ISLAND_REASONS_SHOWN)) {
const where = r.loc === null ? "" : `${at(r.loc)}: `;
out.push(` ${c(DIM, capMessage(`${where}${describeReason(r)}`))}`);
}
const more = island.reasons.length - ISLAND_REASONS_SHOWN;
const details = [
more > 0
? `+${more} more reason${more === 1 ? "" : "s"} in ${counted(island.modules, "module")}`
: "",
island.carries.length > 0 ? `carries ${island.carries.join(", ")}` : "",
].filter((t) => t !== "");
if (details.length > 0) out.push(` ${c(DIM, details.join("; "))}`);
}
for (const m of report.userModules) {
out.push(
` ${c(YELLOW, "marked".padEnd(widest))} ${displayPath(m.file, root)} ${c(DIM, `(user module, marked by ${m.source === "cli" ? "--node-island" : "nodeIslands.modules"})`)}`,
);
}
for (const v of report.violations) {
out.push(` ${c(RED, v.code)} ${capMessage(v.message)}`);
}
const shared = report.packages.filter((p) => p.status === "shared");
if (shared.length > 0) {
out.push(
` ${c(DIM, `reached from native code and from an island: ${shared.map((p) => p.package).join(", ")}`)}`,
);
}
const native = report.packages.filter((p) => p.status === "native");
if (native.length > 0) {
out.push(
` ${c(DIM, `${counted(native.length, "package")} stay${native.length === 1 ? "s" : ""} native: ${native.map((p) => p.package).join(", ")}`)}`,
);
}
if (report.unknownPackages.length > 0) {
out.push(
` ${c(YELLOW, "note")} not imported by the program: ${report.unknownPackages.join(", ")}`,
);
}
if (report.islands.length > 0) {
out.push(
` ${c(DIM, "hint: scriptc coverage --explain-island=<package> lists every reason")}`,
);
}
}
/** `scriptc coverage --explain-island=<package>`: why a package would run
* in a Node island — every module's first reason, the native import sites
* (the boundary), the packages inside its closure, and what later
* milestones decide. */
export function renderExplainIsland(
report: NodeIslandReport | undefined,
query: string,
opts: CoverageRenderOptions = {},
): string {
const c = (code: string, s: string) => (opts.color ? code + s + RESET : s);
const locator = new SourceLocator(opts.sourceTexts);
const at = (loc: SrcLoc) => {
const p = locator.position(loc.file, loc.start);
return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`;
};
const out: string[] = [];
const matches = (report?.islands ?? []).filter(
(i) => i.package === query || islandName(i) === query,
);
if (matches.length === 0) {
const pkg = (report?.packages ?? []).find((p) => p.package === query);
if (pkg === undefined) {
out.push(` '${query}' is not a Node island: no npm package of that name is in the program`);
} else if (pkg.status === "inside") {
out.push(
` '${query}' is not a Node island of its own: only ${pkg.islands.join(", ")} import${pkg.islands.length === 1 ? "s" : ""} it, so it would run inside that island`,
);
} else if (pkg.status === "shared") {
out.push(
` '${query}' compiles natively and is also reached from the Node island${pkg.islands.length === 1 ? "" : "s"} ${pkg.islands.join(", ")}`,
);
} else {
out.push(` '${query}' is not a Node island: it compiles natively`);
}
return out.join("\n");
}
for (const island of matches) {
out.push(
` ${c(BOLD, "Node island")} ${islandName(island)} ${c(DIM, "(would-be, report-only)")}`,
);
if (island.path !== null) out.push(` path ${displayPath(island.path, opts.root)}`);
out.push(` class ${island.class}: ${classDescription(island.class)}`);
out.push(
` decision ${island.decision === "auto" ? "automatic (npm code outside the native subset)" : island.decision === "cli" ? "marked with --node-island" : 'marked "node" in scriptc.packages'}`,
);
out.push(
` evaluation eager at its module position ${c(DIM, "(deferred evaluation is decided by a later milestone)")}`,
);
if (island.reasons.length > 0) {
out.push(` reasons ${c(DIM, "(first per module; decisive class first)")}`);
const shown = new Set<string>();
for (const r of island.reasons) {
const key = r.loc === null ? "" : r.loc.file;
if (shown.has(key)) continue;
shown.add(key);
const where = r.loc === null ? "package" : at(r.loc);
out.push(` ${r.class.padEnd(12)} ${where}`);
out.push(` ${c(DIM, capMessage(describeReason(r)))}`);
}
const counts = new Map<string, number>();
for (const r of island.reasons) counts.set(r.class, (counts.get(r.class) ?? 0) + 1);
const summary = [...counts].map(([cls, k]) => `${k} ${cls}`).join(", ");
out.push(` all reasons ${island.reasons.length} (${summary})`);
}
if (island.importedFrom.length > 0) {
out.push(` boundary ${counted(island.importedFrom.length, "native import site")}:`);
for (const loc of island.importedFrom.slice(0, SITES_SHOWN)) out.push(` ${at(loc)}`);
if (island.importedFrom.length > SITES_SHOWN)
out.push(` ${c(DIM, `+${island.importedFrom.length - SITES_SHOWN} more`)}`);
out.push(
` ${c(DIM, "copy and handle decisions per boundary site come with compiler-managed islands")}`,
);
} else {
out.push(
` boundary ${c(DIM, "no native import site (reached only from other islands)")}`,
);
}
if (island.carries.length > 0) out.push(` carries ${island.carries.join(", ")}`);
}
return out.join("\n");
}
function nodeIslandsEnvelope(
report: NodeIslandReport,
locator: SourceLocator,
): NodeIslandsEnvelope {
const pos = (loc: SrcLoc): SourcePosition => ({
file: loc.file,
...locator.position(loc.file, loc.start),
});
const entry = (i: NodeIslandEntry) => {
const first = i.reasons[0];
return {
package: i.package,
version: i.version,
path: i.path,
class: i.class,
reason: i.reason,
diagnostic: i.diagnostic,
decision: i.decision,
evaluation: i.evaluation,
...(first !== undefined && first.loc !== null ? { at: pos(first.loc) } : {}),
reasons: i.reasons.map((r) => ({
class: r.class,
rule: r.rule,
reason: r.reason,
code: r.code,
message: r.message,
source: r.source,
...(r.loc !== null ? { at: pos(r.loc) } : {}),
})),
carries: i.carries,
importedFrom: i.importedFrom.map(pos),
};
};
return {
mode: report.mode,
policy: report.policy,
islands: report.islands.map(entry),
userModules: report.userModules.map((m) => ({ file: m.file, decision: m.source })),
npmPackages: report.packages.map((p) => ({
package: p.package,
version: p.version,
path: p.path,
status: p.status,
islands: p.islands,
})),
violations: report.violations.map((v) => ({ code: v.code, message: v.message, ...pos(v.loc) })),
unknownPackages: report.unknownPackages,
};
}
@@ -15,13 +15,15 @@ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { NpmStaticStatus } from "./report.js";
import type { WasmModuleReport } from "../wasm/discover.js";
import type { NodeIslandReport } from "../node-islands/summary.js";
import { nodeIslandOptionsKey } from "../node-islands/policy.js";
import {
FrontendInputTracker,
frontendInputsStillMatch,
type FrontendInputSnapshot,
} from "../frontend/input-tracker.js";
const SCHEMA = "scriptc.coverage-verdict.v2";
const SCHEMA = "scriptc.coverage-verdict.v3";
/** Key-value storage for verdict entries (best effort: failures miss). */
export interface VerdictStore {
@@ -41,6 +43,7 @@ export interface CachedCoverage {
performance?: ScrDiagnostic[];
npmStatic?: NpmStaticStatus[];
wasmModules?: WasmModuleReport[];
nodeIslands?: NodeIslandReport;
preflightFailed: boolean;
}
@@ -130,6 +133,10 @@ export function verdictKey(
...(opts.tier === "static-only" ? ["tier=static-only"] : []),
// The module files' contents are probes; their names are options.
...(opts.wasm !== undefined && opts.wasm.length > 0 ? [`wasm=${opts.wasm.join("\n")}`] : []),
// The Node island policy's command-line half shapes the report.
...(opts.nodeIslands !== undefined
? [`node-islands=${nodeIslandOptionsKey(opts.nodeIslands)}`]
: []),
].join("\0"),
);
}
@@ -153,6 +160,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null {
if (coverage.performance !== undefined) out.performance = coverage.performance;
if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic;
if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules;
if (coverage.nodeIslands !== undefined) out.nodeIslands = coverage.nodeIslands;
return out;
}
@@ -171,6 +179,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult
if (cached.performance !== undefined) coverage.performance = cached.performance;
if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic;
if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules;
if (cached.nodeIslands !== undefined) coverage.nodeIslands = cached.nodeIslands;
return { coverage, sourceTexts };
}
@@ -65,6 +65,13 @@
* notes, also warnings (coverage --fail-on=dynamic opts in):
* likely-hot functions that use untyped values (SC6101,
* diagnostics/dynamic-tier.ts)
* SC8xxx Node island policy: packages and modules outside the native
* subset that run (or, in report-only mode, would run) in a
* Node island — unknown project configuration (SC8004), a
* package required to be native (SC8003) or every island
* refused (SC8002) while outside the native subset, and the
* R2 object-model constructs in user JavaScript (SC8101–SC8105,
* behind --strict-user-js)
* SC9xxx internal compiler errors (still source-anchored)
*/
import type { SrcLoc } from "../ir/ir.js";
@@ -92,6 +99,11 @@ export interface ScrDiagnostic {
* the error may not reproduce under the project's tsc. Machine-readable
* output reports these under the "environment" category. */
typeWorld?: boolean;
/** Why the construct is outside the native subset, as a Node island
* reason id (node-islands/rules.ts), at the refusal sites whose code
* alone does not say it (a computed require(), a native addon, `with`).
* Report-only: never part of the rendered message. */
reason?: string;
/** A runtime fence in a module's top-level code, outside every function
* and class member body: it throws when the module initializes, which is
* program startup for a module the entry imports. */
@@ -208,6 +220,75 @@ export function unrelatedClassCastDivergenceDiag(
};
}
/* ── SC8xxx: Node island policy ───────────────────────────────────────── */
/** SC8004 — an unknown key or value in `scriptc.packages` or
* `scriptc.nodeIslands`. */
export function projectConfigDiag(
path: string,
detail: string,
hint: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8004",
message: `package.json '${path}' ${detail}`,
loc,
hint,
};
}
/** SC8002 — Node islands are off (or limited to marked islands) and an
* npm package is outside the native subset. */
export function nodeIslandsOffDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8002",
message: `npm package '${pkg}' is outside the native subset (${why}) and Node islands are off`,
loc,
hint: `allow Node islands (--node-islands=auto), mark it with --node-island=${pkg}, or replace the dependency`,
};
}
/** SC8002 — a user module is marked to run in a Node island while
* Node islands are off. */
export function nodeIslandsOffModuleDiag(
file: string,
source: "config" | "cli",
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8002",
message: `user module '${file}' is marked to run in Node (${source === "cli" ? "--node-island" : "scriptc.nodeIslands.modules"}) and Node islands are off`,
loc,
hint: "allow Node islands (--node-islands=auto or explicit), or remove the marking",
};
}
/** SC8003 — a package marked "native" is outside the native subset. */
export function nativePackageDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8003",
message: `npm package '${pkg}' is marked native but is outside the native subset: ${why}`,
loc,
hint: `remove the "native" marking to let it run in a Node island, or replace the construct the reason names`,
};
}
/** SC8101–SC8105 — an R2 object-model construct in the program's own
* JavaScript (R2.1 constructor functions … R2.5 builtin patching). */
export function userJsObjectModelDiag(
code: "SC8101" | "SC8102" | "SC8103" | "SC8104" | "SC8105",
construct: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code,
message: `${construct} needs a mutable JavaScript object model, which native code does not have`,
loc,
hint: "rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules",
};
}
/* ── SC5xxx: native FFI ───────────────────────────────────────────────── */
/** SC5001 — the outbound native-FFI manifest is malformed or unreadable. */
@@ -773,6 +854,7 @@ export function nativeAddonDiag(specifier: string, loc: SrcLoc): ScrDiagnostic {
loc,
milestone: "later",
hint: "expose the native operation as a plain C ABI function in an object or static archive, then bind a signature-only TypeScript declaration with --ffi; Node-API callbacks themselves require Node (https://scriptc.dev/ffi#replacing-a-node-api-addon)",
reason: "native-addon",
};
}
+57 -1
View File
@@ -148,10 +148,60 @@ export interface DiagnosticsEnvelope {
/** coverage only: the WebAssembly modules the build can see, with their
* size, validity, proposals and the sources that found them. */
wasmModules?: WasmModuleReport[];
/** coverage only, when there is something to report: the packages and
* user modules that would run in a Node island (report-only). */
nodeIslands?: NodeIslandsEnvelope;
groups: DiagnosticGroup[];
diagnostics: LocatedDiagnostic[];
}
/** One reason a package is outside the native subset. */
export interface NodeIslandReasonEnvelope {
class: string;
/** R1.1–R2.5, or null for gap and packaging reasons. */
rule: string | null;
reason: string;
code: string | null;
message: string;
source: string;
at?: SourcePosition;
}
/** `scriptc coverage --print=diagnostics`: the Node island decisions. In
* this version they are would-be decisions (mode "would-be"): the build
* does not change. */
export interface NodeIslandsEnvelope {
mode: "would-be";
policy: "auto" | "explicit" | "off";
/** Gap islands first, then by class and name. */
islands: {
package: string;
version: string | null;
path: string | null;
class: string;
reason: string;
diagnostic: string | null;
decision: string;
evaluation: string;
at?: SourcePosition;
reasons: NodeIslandReasonEnvelope[];
carries: string[];
importedFrom: SourcePosition[];
}[];
userModules: { file: string; decision: string }[];
/** Every npm package that is not an island: native, inside an island's
* closure, or shared between native code and an island. */
npmPackages: {
package: string;
version: string | null;
path: string | null;
status: string;
islands: string[];
}[];
violations: (SourcePosition & { code: string; message: string })[];
unknownPackages: string[];
}
/** The divergence band: compiles, but the program can behave differently
* from Node. Warnings, never build failures. */
export function isDivergenceCode(code: string): boolean {
@@ -166,6 +216,7 @@ export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
if (code === "SC0003") return "invalid-source";
if (code === "SC0002" || code === "SC0004") return "environment";
if (/^SC[35]\d{3}$/.test(code)) return "environment";
if (code === "SC8004" || code === "SC8005" || code === "SC8012") return "environment";
if (/^SC9\d{3}$/.test(code)) return "internal";
return "unsupported";
}
@@ -186,6 +237,8 @@ export function hintOf(diag: ScrDiagnostic): string {
return "restate the value's type inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')";
if (/^SC2\d{3}$/.test(code))
return "restate the type inside the compilable set (https://scriptc.dev/limitations describes the boundary)";
if (/^SC8\d{3}$/.test(code))
return "see the Node island policy: npm code outside the native subset runs in Node, user code must be marked (scriptc coverage --explain-island=<package>)";
if (/^SC9\d{3}$/.test(code))
return "this is a compiler bug — please report it with the program that triggers it";
return "see https://scriptc.dev/limitations";
@@ -281,7 +334,10 @@ export function locateDiagnostic(
return {
code: diag.code,
category: categoryOf(diag),
severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error",
severity:
isDivergenceCode(diag.code) || isPerformanceCode(diag.code) || diag.code === "SC8004"
? "warning"
: "error",
scope,
file: diag.loc.file,
line: start.line,
+23 -4
View File
@@ -102,14 +102,12 @@ export function renderDiagnostic(
frame.push(
c(DIM, ` ${" ".repeat(gutterWidth)} | `) +
" ".repeat(caretCol) +
c(/^SC6\d{3}$/.test(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)),
c(isWarningCode(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)),
);
emitLine(lineNum + 1);
}
// The divergence band (SC6xxx) is the one warning severity: the program
// compiles, and the site can behave differently from Node.
const warning = /^SC6\d{3}$/.test(diag.code);
const warning = isWarningCode(diag.code);
out.push(
`${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`,
);
@@ -154,6 +152,27 @@ export function renderDiagnostics(
/** Divergence warnings after a successful build: the rendered sites plus a
* one-line count, on stderr. */
/** The warning severities: the divergence band (SC6xxx), where the program
* compiles and the site can behave differently from Node, and unknown
* settings in the project's scriptc configuration (SC8004), which builds do
* not use. */
export function isWarningCode(code: string): boolean {
return /^SC6\d{3}$/.test(code) || code === "SC8004";
}
/** A build's SC8004 warnings: the unknown scriptc settings it ignored. */
export function renderConfigWarnings(
diags: readonly ScrDiagnostic[],
sourceTextByFile: Map<string, string>,
opts: RenderOptions = {},
): string {
const n = diags.length;
return (
renderDiagnostics([...diags], sourceTextByFile, opts) +
`\n\n${n} warning${n === 1 ? "" : "s"}: the build does not use ${n === 1 ? "this setting" : "these settings"}; scriptc coverage reports Node islands once they are valid.`
);
}
export function renderWarnings(
diags: readonly ScrDiagnostic[],
sourceTextByFile: Map<string, string>,
+5 -1
View File
@@ -122,6 +122,10 @@ export function prepareExecutableModule(
mod: lowered.module!,
sourceTexts,
stats: lowered.stats,
warnings: [...(lowered.divergences ?? []), ...(lowered.performance ?? [])],
warnings: [
...(fe.warnings ?? []),
...(lowered.divergences ?? []),
...(lowered.performance ?? []),
],
};
}
+28 -2
View File
@@ -4,6 +4,10 @@ import type { AnalyzeOptions, AnalyzeResult } from "../compile-types.js";
import { loadFfiProfile, type FfiProfile } from "../ffi/ffi-manifest.js";
import { provenanceSources } from "./provenance-registry.js";
import type { FrontendFactory } from "./pipeline.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
import { buildNodeIslandReport } from "../node-islands/report.js";
import { hasNodeIslandFindings } from "../node-islands/summary.js";
/** Analysis without codegen: how much of the program compiles statically.
* Unlike compile(), lowering diagnostics are data here, not failure. */
@@ -34,7 +38,9 @@ export function analyzeWithFrontend(
}
ffi = loaded.profile;
}
const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes);
const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes, undefined, {
nodeIslands: true,
});
try {
const emptyStats = {
statementsTotal: 0,
@@ -96,6 +102,23 @@ export function analyzeWithFrontend(
...(opts.wasm !== undefined && opts.wasm.length > 0 ? { wasmFiles: opts.wasm } : {}),
});
const provenance = provenanceSources();
// Node islands, report-only: which packages and modules would run in
// Node, from the detector's scan and the policy. Code generation does
// not depend on it.
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
const islandReport =
fe.nodeIslandScan === undefined
? null
: buildNodeIslandReport({
entry: entryPath,
scan: fe.nodeIslandScan({ userJs: policy.strictUserJs }),
npm: fe.npmStatic,
diagnostics: [...lowered.diagnostics, ...(lowered.unreached?.diagnostics ?? [])],
fences: [...lowered.runtimeFences, ...(lowered.unreached?.runtimeFences ?? [])],
policy,
});
const nodeIslands =
islandReport !== null && hasNodeIslandFindings(islandReport) ? islandReport : null;
return {
coverage: {
file: entryPath,
@@ -103,7 +126,9 @@ export function analyzeWithFrontend(
// The import fences report as blockers alongside the statement-level
// ones (use sites of the fenced bindings emit matching diagnostics,
// which the report groups with these).
diagnostics: [...preflight, ...lowered.diagnostics],
// --strict-user-js: R2 constructs in user JavaScript are blockers
// (SC8101–SC8105), as scope B's builds will make them errors.
diagnostics: [...preflight, ...lowered.diagnostics, ...(nodeIslands?.userJs ?? [])],
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}),
...(lowered.performance !== undefined ? { performance: lowered.performance } : {}),
@@ -116,6 +141,7 @@ export function analyzeWithFrontend(
...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}),
...(lowered.tiers !== undefined ? { tiers: lowered.tiers } : {}),
...(lowered.wasmModules !== undefined ? { wasmModules: lowered.wasmModules } : {}),
...(nodeIslands !== null ? { nodeIslands } : {}),
preflightFailed: false,
},
sourceTexts: fe.sourceTexts(),
@@ -237,7 +237,8 @@ export function lowerCreateRequireCall(
const surface = "createRequire's require with a computed specifier or this argument shape";
const hint =
'the compiled module graph is fixed at build time — the one lowered form is require("<static string literal>"); native addons are not loaded';
if (!isJsSourceFile(ts.getSourceFileOfNode(call))) lowerer.noLowering(surface, call, hint);
if (!isJsSourceFile(ts.getSourceFileOfNode(call)))
lowerer.noLowering(surface, call, hint, undefined, "dynamic-require");
return deferredCreateRequireThrow(lowerer, call, surface, hint, loc);
}
const spec = cr.spec;
@@ -946,6 +946,8 @@ function appendArgumentsParameter(
"SC1090",
node,
"parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases)",
undefined,
"sloppy-arguments",
);
}
}
@@ -532,6 +532,8 @@ export function lowerUncompiledRequireCall(
"SC1090",
call,
'require() with a computed specifier (the compiled module graph is fixed at build time: require("<string literal>") lowers)',
undefined,
"dynamic-require",
);
}
if (canonicalBuiltinModule(spec) !== null) return null;
@@ -842,7 +842,17 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
"deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead",
eval: "runtime code evaluation cannot be compiled ahead of time",
};
lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined);
lowerer.noLowering(
expr.text,
expr,
globalHints[expr.text],
sym ?? undefined,
expr.text === "WeakRef" || expr.text === "FinalizationRegistry"
? "weak-ref"
: expr.text === "eval"
? "code-from-string"
: undefined,
);
}
}
// An ambient global nothing defines compiles to exactly what Node does
@@ -586,6 +586,8 @@ export function fenceNodeModuleMutation(
`${action} through require.cache`,
target,
"a compiled binary has a fixed module graph; cache lookup and Object.keys(require.cache) are read-only",
undefined,
"loader-hook",
);
}
if (
@@ -2222,6 +2222,8 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression
call,
"dynamic import() of computed specifiers (the module graph resolves at " +
"build time — the specifier must be a string literal)",
undefined,
"dynamic-import",
);
}
if (call.arguments.nodes.length !== 1) {
@@ -8830,8 +8830,11 @@ export class Lowerer {
node: ts.Node,
featureOverride?: string,
hintOverride?: string,
reason?: string,
): never {
this.pushDiag(unsupportedDiag(code, locOf(node), featureOverride, hintOverride));
const diag = unsupportedDiag(code, locOf(node), featureOverride, hintOverride);
if (reason !== undefined) diag.reason = reason;
this.pushDiag(diag);
throw new PoisonError();
}
@@ -9057,8 +9060,16 @@ export class Lowerer {
* nothing lowers. Poisons the statement like every other rejection.
* `sym`, when given, picks the wording: surface declared only by the
* adopted @types/node is blamed at @types/node. */
noLowering(surface: string, node: ts.Node, hint?: string, sym?: ts.Symbol | null): never {
this.pushDiag(noLoweringDiag(surface, locOf(node), hint, this.nodeTypesOnlySymbol(sym)));
noLowering(
surface: string,
node: ts.Node,
hint?: string,
sym?: ts.Symbol | null,
reason?: string,
): never {
const diag = noLoweringDiag(surface, locOf(node), hint, this.nodeTypesOnlySymbol(sym));
if (reason !== undefined) diag.reason = reason;
this.pushDiag(diag);
throw new PoisonError();
}
@@ -42,6 +42,7 @@
import { dirname, join, resolve } from "node:path";
import { packageShimRoot, tsgoPath } from "./dts-paths.js";
import { trackedDirectoryExists, trackedReadFile, trackedRealpath } from "./input-tracker.js";
import { readProjectConfig } from "../project-config.js";
import { packageNameOfSpecifier } from "./workspace-registry.js";
import {
BUILTIN_ALIAS_PACKAGES,
@@ -204,36 +205,12 @@ export function packageShimEntries(): string[] {
let unloadedPackages: ReadonlySet<string> = new Set();
/** Reads the project's package-shim configuration: the `scriptc.packages`
* map of the entry's nearest package.json. Called once per program load. */
* entries marked "unloaded" in the entry's nearest package.json
* (project-config.ts validates the rest). Called once per program load. */
export function configurePackageShims(entryPath: string): void {
const packages = new Set<string>();
for (let dir = dirname(resolve(entryPath)); ;) {
const text = trackedReadFile(join(dir, "package.json"));
if (text !== null) {
let parsed: unknown = null;
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
const config =
parsed !== null && typeof parsed === "object"
? (parsed as Record<string, unknown>)["scriptc"]
: undefined;
const map =
config !== null && typeof config === "object"
? (config as Record<string, unknown>)["packages"]
: undefined;
if (map !== null && typeof map === "object") {
for (const [name, kind] of Object.entries(map as Record<string, unknown>)) {
if (kind === "unloaded") packages.add(name);
}
}
break;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
for (const [name, setting] of readProjectConfig(entryPath).packages) {
if (setting.unloaded) packages.add(name);
}
unloadedPackages = packages;
}
@@ -1,4 +1,4 @@
import { runFrontend, type Frontend } from "./pipeline.js";
import { runFrontend, type Frontend, type FrontendOptions } from "./pipeline.js";
import { loadProgram } from "./program.js";
import { FrontendServices, type ComptimeEvaluator } from "./services.js";
import { createNativeTs7Api } from "./ts7/native-api.js";
@@ -13,6 +13,7 @@ export function runNativeFrontend(
externalTypes?: Readonly<Record<string, string>>,
evaluateComptime?: ComptimeEvaluator,
libraryNpmStatic: readonly string[] = [],
options: FrontendOptions = {},
): Frontend {
const services = new FrontendServices(
(options) => createNativeTs7Api({ ...options, executable }),
@@ -31,6 +32,7 @@ export function runNativeFrontend(
npmStatic,
externalTypes,
libraryNpmStatic,
options,
);
return {
...frontend,
+125 -6
View File
@@ -46,6 +46,14 @@ import { CONTRACT_TYPES_MODULE } from "./contract-types.js";
import { THREADS_MODULE } from "./threads-import.js";
import { erasedTypeOnlyImport } from "./type-only-imports.js";
import { EXPERIMENTAL_MODULE } from "./experimental-import.js";
import { readProjectConfig } from "../project-config.js";
import { packageDirOf } from "../node-islands/origin.js";
import {
scanFiles,
type NodeIslandScan,
type RefusalKind,
type RefusalRecord,
} from "../node-islands/scan.js";
/** Where a package-anchored preflight diagnostic sits, as
* `<package path>:<line>` — the SC2013 refusal that drops the package
@@ -104,17 +112,33 @@ export interface Frontend {
npmStatic: NpmStaticStatus[];
/** Each judged npm package's first import site, used to anchor refusals. */
npmImportSites: ReadonlyMap<string, SrcLoc>;
/** The Node island detector's input (report-only): the program's
* modules with their pre-scan markers, package import edges, and how
* each refused package was refused. Call before dispose. */
nodeIslandScan?: ((opts: { userJs: boolean }) => NodeIslandScan) | undefined;
/** Diagnostics that do not fail a build: unknown settings in the
* project's scriptc configuration (SC8004), which builds do not use. */
warnings?: ScrDiagnostic[];
/** Releases the frontend's resources (the spawned tsgo server). Call
* exactly once, after the last lower(). */
dispose: () => void;
}
/** Hosts own transport lifetimes; shared compiler stages own the frontend. */
/** What a frontend computes beyond the build's own needs. */
export interface FrontendOptions {
/** 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. */
nodeIslands?: boolean;
}
export type FrontendFactory = (
entryPath: string,
npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic?: readonly string[],
options?: FrontendOptions,
) => Frontend;
/** Adds the program modules a literal `import()` reaches to `scanned`,
@@ -385,12 +409,37 @@ function packagesNamedByDiag(message: string, optedIn: ReadonlySet<string>): Set
return hits;
}
/** The modules of packages the drop-and-reload loop is about to remove,
* scanned on the program that still holds them. Report-only: a detector
* failure here never changes the build's outcome. */
function scanDroppedPackages(
load: LoadResult,
packages: readonly string[],
into: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] },
): void {
if (packages.length === 0) return;
try {
const wanted = new Set(packages);
const files = load.program.getImplementationSourceFiles().filter((sf) => {
if (sf.isDeclarationFile) return false;
const at = packageDirOf(sf.fileName);
return at !== null && wanted.has(at.name);
});
const scanned = scanFiles(load.program, files, { userJs: false, dropped: true, edges: true });
into.modules.push(...scanned.modules);
into.edges.push(...scanned.edges);
} catch {
/* report-only detection */
}
}
export function runFrontend(
entryPath: string,
loadProgram: ProgramLoader,
npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic: readonly string[] = [],
options: FrontendOptions = {},
): Frontend {
// A preflight or package probe may throw after its host has opened. Track
// only live loads, releasing each immediately on normal fallback; this
@@ -430,6 +479,7 @@ export function runFrontend(
externalTypes,
[...new Set(names)],
transitive,
options,
);
} catch (error) {
for (const load of active) {
@@ -451,6 +501,7 @@ function loadFrontend(
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic: readonly string[] = [],
transitive: Set<string> = new Set(),
options: FrontendOptions = {},
): Frontend {
// Resolver package/workspace metadata is intentionally shared across the
// several load attempts of ONE auto-detection fixpoint, but never across
@@ -459,6 +510,16 @@ function loadFrontend(
clearResolveCaches();
const statuses: NpmStaticStatus[] = [];
const npmSites = new Map<string, SrcLoc>();
// Node island detection (report-only): how each package was refused, and
// the modules of packages removed from the program.
const refusals: RefusalRecord[] = [];
const droppedScan: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] } = {
modules: [],
edges: [],
};
const recordRefusal = (pkg: string, kind: RefusalKind, diag: ScrDiagnostic | null): void => {
refusals.push({ package: pkg, kind, diag });
};
// Explicit library attempts bypass only the auto-selection heuristics.
// The shared preflight/lowering refusal and dependency-closure checks remain.
const judged = new Set<string>(libraryNpmStatic);
@@ -514,6 +575,7 @@ function loadFrontend(
status: "refused",
detail: `mapped as an external host module by --external-types (${specs.map((s) => JSON.stringify(s)).join(", ")})`,
});
recordRefusal(pkg, "external", null);
return false;
});
}
@@ -547,19 +609,25 @@ function loadFrontend(
const typecheckRefused = new Set<string>();
while (effective.size > 0) {
const reasons = new Map<string, string>(npmStaticOffenders());
const kinds = new Map<string, { kind: RefusalKind; diag: ScrDiagnostic | null }>();
for (const pkg of reasons.keys()) kinds.set(pkg, { kind: "offender", diag: null });
// Bundler interop the rewrite could not finish refuses its package
// only where the compiled program can load the file.
if (npmStaticFileDegrades().size > 0) {
const loadable = npmStaticLoadableFiles(load.program, load.moduleOrder);
for (const [file, { pkg, reason }] of npmStaticFileDegrades()) {
if (!reasons.has(pkg) && (loadable === null || loadable.has(file)))
if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) {
reasons.set(pkg, reason);
kinds.set(pkg, { kind: "degrade", diag: null });
}
}
}
for (const d of preflight) {
const pkg = npmStaticPackageOfPath(d.loc.file);
if (pkg !== null && !reasons.has(pkg))
if (pkg !== null && !reasons.has(pkg)) {
reasons.set(pkg, `${d.code} at ${packageDiagWhere(load, d.loc)}: ${d.message}`);
kinds.set(pkg, { kind: "preflight", diag: d });
}
}
if (![...reasons.keys()].some((p) => effective.has(p))) {
const named = new Map<string, number>();
@@ -571,6 +639,7 @@ function loadFrontend(
}
for (const [pkg, count] of named) {
typecheckRefused.add(pkg);
kinds.set(pkg, { kind: "typecheck", diag: null });
reasons.set(
pkg,
`its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files (bundler-emitted surfaces type only as far as inference reaches)`,
@@ -579,9 +648,12 @@ function loadFrontend(
}
const dropping = [...reasons.keys()].filter((p) => effective.has(p));
if (dropping.length === 0) break;
if (options.nodeIslands === true) scanDroppedPackages(load, dropping, droppedScan);
for (const p of dropping) {
effective.delete(p);
statuses.push({ package: p, status: "refused", detail: reasons.get(p)! });
const kind = kinds.get(p);
recordRefusal(p, kind?.kind ?? "offender", kind?.diag ?? null);
}
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
@@ -601,9 +673,12 @@ function loadFrontend(
// graceful per-package degradation, never a failed gate the user cannot
// act on (the note carries the why).
if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) {
const lastResortDropped: string[] = [];
const dropWithNote = (p: string): void => {
effective.delete(p);
typecheckRefused.add(p);
lastResortDropped.push(p);
recordRefusal(p, "typecheck", null);
statuses.push({
package: p,
status: "refused",
@@ -625,11 +700,15 @@ function loadFrontend(
probe.dispose();
if (probeDiags.some((d) => d.code === "SC0001")) dropWithNote(p);
}
if (options.nodeIslands === true)
scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
preflight = checkPreflight(load);
if (preflight.some((d) => d.code === "SC0001") && effective.size > 0) {
for (const p of [...effective]) dropWithNote(p);
if (options.nodeIslands === true)
scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
preflight = checkPreflight(load);
@@ -650,8 +729,16 @@ function loadFrontend(
}
const finalLoad = load;
// An unknown key or value in the project's scriptc configuration makes
// the Node island report unavailable (SC8004). The settings configure
// only that report: a build warns about them and goes on.
const projectConfig = readProjectConfig(entryPath);
const configDiagnostics = projectConfig.diagnostics;
const report = options.nodeIslands === true;
return {
preflight,
preflight:
report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight,
...(!report && configDiagnostics.length > 0 ? { warnings: configDiagnostics } : {}),
entryText: () => finalLoad.entry.text,
entryExports: () => entryFunctionExports(finalLoad.entry),
// The contract scans the PROGRAM's source files, not the runtime
@@ -678,14 +765,23 @@ function loadFrontend(
// are in moduleOrder like any program module, so their bytes join the
// library identity hashes (source_hash/build_id) — compiled code is
// identity, whatever directory it came from.
sourceTexts: () =>
new Map<string, string>(
sourceTexts: () => {
const texts = new Map<string, string>(
[
finalLoad.entry,
...finalLoad.moduleOrder,
...finalLoad.program.getImplementationSourceFiles().filter((sf) => !sf.isDeclarationFile),
].map((sf) => [sf.fileName, sf.text]),
),
);
// SC8004 diagnostics point into the project's package.json.
if (
configDiagnostics.length > 0 &&
projectConfig.manifest !== null &&
projectConfig.manifestText !== null
)
texts.set(projectConfig.manifest, projectConfig.manifestText);
return texts;
},
lower: (opts) => {
const lowered = lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, {
...opts,
@@ -705,6 +801,29 @@ function loadFrontend(
},
npmStatic: statuses,
npmImportSites: npmSites,
nodeIslandScan:
options.nodeIslands !== true
? undefined
: ({ userJs }) => {
const files = new Set<SourceFile>([finalLoad.entry, ...finalLoad.moduleOrder]);
addDynamicImportGraph(finalLoad, files);
// Package edges matter only when the program has npm packages: one
// that loads, one that was refused, or one judged and dropped.
const npm =
statuses.length > 0 ||
droppedScan.modules.length > 0 ||
[...files].some((sf) => sf.fileName.includes("/node_modules/"));
const scanned = scanFiles(finalLoad.program, [...files], {
userJs,
dropped: false,
edges: npm,
});
return {
modules: [...droppedScan.modules, ...scanned.modules],
edges: [...droppedScan.edges, ...scanned.edges],
refusals,
};
},
dispose: finalLoad.dispose,
};
}
+10 -2
View File
@@ -214,11 +214,14 @@ export {
export {
renderCoverage,
renderExplainDynamic,
renderExplainIsland,
coverageEnvelope,
coveragePasses,
type CoverageInput,
type CoverageFailOn,
} from "./coverage/report.js";
export type { NodeIslandReport, NodeIslandEntry } from "./node-islands/summary.js";
export type { NodeIslandOptions, NodeIslandsMode } from "./node-islands/policy.js";
export {
TIER_MODES,
type DemotedUnit,
@@ -416,8 +419,13 @@ export async function analyzeCached(
return result;
}
const nodeFrontend: FrontendFactory = (entry, npmStatic, externalTypes, libraryNpmStatic) =>
runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic);
const nodeFrontend: FrontendFactory = (
entry,
npmStatic,
externalTypes,
libraryNpmStatic,
options,
) => runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic, options);
/** The checker's part of the executable cache key (`checkerCacheIdentity`), as the CLI's startup route computes it. */
function checkerField(): { checker?: string } {
+2 -1
View File
@@ -112,7 +112,7 @@ export class NativeCompiler {
private readonly cache: NativeCache | null = null,
) {
const evaluator = toolchain.comptimeExecutable;
this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic) =>
this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic, options) =>
runNativeFrontend(
entry,
toolchain.ts7Executable,
@@ -123,6 +123,7 @@ export class NativeCompiler {
: (source, timeout) =>
evaluateNativeComptime(source, timeout, toolchain.ts7Executable, evaluator),
libraryNpmStatic,
options,
);
}
@@ -0,0 +1,131 @@
/* Diagnostic → Node island class (DESIGN.md §1.3). A refusal in a module
* of an npm package is a reason for that package to run in a Node island;
* its class says why: the runtime itself (R1), a mutable object model (R2),
* a shipping problem (packaging), or a construct inside the native subset
* that scriptc cannot compile yet (gap). The reason id on the diagnostic
* (ScrDiagnostic.reason) decides first; the code and message decide for
* refusal sites that do not carry one. */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { NodeIslandMarker } from "./detect.js";
import { classOfRule, ruleInfo, ruleOfReason } from "./rules.js";
import type { RefusalRecord } from "./scan.js";
import type { NodeIslandReason, NodeIslandReasonSource } from "./summary.js";
/** Message shapes of refusal sites that predate reason ids. */
const MESSAGE_REASONS: { code: string; pattern: RegExp; reason: string }[] = [
{ code: "SC2010", pattern: /./, reason: "code-from-string" },
{ code: "SC1010", pattern: /'(?:node:)?vm'/, reason: "vm" },
{ code: "SC1090", pattern: /^dynamic import\(\) of computed/, reason: "dynamic-import" },
{ code: "SC1090", pattern: /require\(\) with a computed specifier/, reason: "dynamic-require" },
{ code: "SC1090", pattern: /'with' statement/, reason: "with" },
{ code: "SC1090", pattern: /sloppy-mode parameter aliases/, reason: "sloppy-arguments" },
{ code: "SC2020", pattern: /^'(?:eval|Function)'/, reason: "code-from-string" },
{
code: "SC2020",
pattern: /createRequire's require with this argument shape/,
reason: "dynamic-require",
},
{ code: "SC2020", pattern: /native addon/, reason: "native-addon" },
{
code: "SC2020",
pattern: /through require\.cache|require\.extensions|Module\._|module\.register/,
reason: "loader-hook",
},
{
code: "SC2020",
pattern: /process\.binding|_linkedBinding|internalBinding|setFlagsFromString/,
reason: "engine-internal",
},
{ code: "SC2020", pattern: /\bWeakRef\b|\bFinalizationRegistry\b/, reason: "weak-ref" },
{ code: "SC1100", pattern: /\bWeakRef\b|\bFinalizationRegistry\b/, reason: "weak-ref" },
];
function reasonIdOf(d: ScrDiagnostic): string | null {
if (d.reason !== undefined) return d.reason;
for (const entry of MESSAGE_REASONS) {
if (entry.code === d.code && entry.pattern.test(d.message)) return entry.reason;
}
return null;
}
/** Codes that are not reasons of the package they appear in: a cascade
* marker repeats a root cause, an import of another refused package is
* that package's island boundary, and divergences compile. */
export function isIslandReasonCode(code: string): boolean {
if (code === "SC2004" || code === "SC2013") return false;
if (/^SC6\d{3}$/.test(code)) return false;
return true;
}
export function classifyDiagnostic(
d: ScrDiagnostic,
source: NodeIslandReasonSource,
): NodeIslandReason {
const id = reasonIdOf(d);
const rule = id === null ? null : ruleOfReason(id);
if (rule !== null && id !== null) {
return {
class: classOfRule(rule),
rule,
reason: id,
code: d.code,
message: d.message,
loc: d.loc,
source,
};
}
return {
class: "gap",
rule: null,
reason: "gap",
code: d.code,
message: d.message,
loc: d.loc,
source,
};
}
export function classifyMarker(m: NodeIslandMarker): NodeIslandReason {
return {
class: classOfRule(m.rule),
rule: m.rule,
reason: m.reason,
code: null,
message: `${ruleInfo(m.rule).title}: ${m.construct}`,
loc: m.loc,
source: "pre-scan",
};
}
/** A refused package's status (pipeline.ts): the drop-and-reload record
* when there is one, else the declaration and shipped-JS eligibility bar,
* which is a packaging reason. */
export function classifyRefusal(
detail: string,
record: RefusalRecord | null,
): NodeIslandReason | null {
if (record !== null && record.kind === "external") return null;
if (record !== null && record.kind === "preflight" && record.diag !== null) {
return classifyDiagnostic(record.diag, "package");
}
if (record !== null && record.kind === "offender" && /require|createRequire/.test(detail)) {
return {
class: "runtime",
rule: "R1.2",
reason: "dynamic-require",
code: "SC2013",
message: detail,
loc: null,
source: "package",
};
}
return {
class: "packaging",
rule: null,
reason: "packaging",
code: "SC2013",
message: detail,
loc: null,
source: "package",
};
}
@@ -0,0 +1,165 @@
import { afterAll, describe, expect, test } from "vitest";
import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js";
import { scanModule } from "./detect.js";
afterAll(() => closeSourceParser());
let serial = 0;
/** The markers of one JavaScript module as `rule reason` strings. */
function markers(source: string, ext = "cjs"): string[] {
const sf = parseSourceFile(`/virtual/m${serial++}.${ext}`, source, "js");
return scanModule(sf).map((m) => `${m.rule} ${m.reason}`);
}
describe("R1 runtime markers", () => {
test("R1.1 code from strings", () => {
expect(markers("eval(code);")).toEqual(["R1.1 code-from-string"]);
expect(markers("(0, eval)(code);")).toEqual(["R1.1 code-from-string"]);
expect(markers("const f = new Function('a', 'return a');")).toEqual(["R1.1 code-from-string"]);
expect(markers("const f = Function('a', body);")).toEqual(["R1.1 code-from-string"]);
expect(markers("const vm = require('node:vm');")).toEqual(["R1.1 vm"]);
expect(markers("import vm from 'vm';", "mjs")).toEqual(["R1.1 vm"]);
});
test("R1.1 exceptions: the global-object idiom and a capability probe in try", () => {
expect(markers("const g = Function('return this')();")).toEqual([]);
expect(markers("let ok; try { new Function(''); ok = true; } catch { ok = false; }")).toEqual(
[],
);
});
test("R1.2 dynamic module loading", () => {
expect(markers("require(name);")).toEqual(["R1.2 dynamic-require"]);
expect(markers("import(`./${x}.js`);", "mjs")).toEqual(["R1.2 dynamic-import"]);
expect(markers("const r = createRequire(import.meta.url); r(spec);", "mjs")).toEqual([
"R1.2 dynamic-require",
]);
expect(markers("require('./a' + '.js'); require.resolve(name); import('./b.js');")).toEqual([]);
});
test("R1.3 native addons", () => {
expect(markers("require('./build/addon.node');")).toEqual(["R1.3 native-addon"]);
expect(markers("const b = require('bindings')('addon');")).toEqual(["R1.3 native-addon"]);
expect(markers("process.dlopen(module, path);")).toEqual(["R1.3 native-addon"]);
});
test("R1.4 loader and require hooks", () => {
expect(markers("require.extensions['.coffee'] = compile;")).toEqual(["R1.4 loader-hook"]);
expect(markers("delete require.cache[key];")).toEqual(["R1.4 loader-hook"]);
expect(markers("const Module = require('module'); Module._load = load;")).toEqual([
"R1.4 loader-hook",
]);
expect(markers("const m = require('node:module'); m.registerHooks({});")).toEqual([
"R1.4 loader-hook",
]);
expect(markers("require('require-in-the-middle');")).toEqual(["R1.4 loader-hook"]);
});
test("R1.5 engine internals", () => {
expect(markers("process.binding('fs');")).toEqual(["R1.5 engine-internal"]);
expect(markers("const v8 = require('v8'); v8.setFlagsFromString('--x');")).toEqual([
"R1.5 engine-internal",
]);
expect(markers("global.gc();")).toEqual(["R1.5 engine-internal"]);
});
test("R1.6 runtime-only globals", () => {
expect(markers("const r = new WeakRef(o);")).toEqual(["R1.6 weak-ref"]);
expect(markers("const f = new FinalizationRegistry(cb);")).toEqual(["R1.6 weak-ref"]);
expect(markers("if (typeof WeakRef !== 'undefined') {}")).toEqual([]);
expect(markers("const o = { WeakRef: 1 }; o.WeakRef;")).toEqual([]);
});
});
describe("R2 object-model markers", () => {
test("R2.1 constructor functions", () => {
expect(markers("function F() {} const f = new F();")).toEqual(["R2.1 constructor-function"]);
expect(markers("function F() {} F.prototype.m = function () {};")).toEqual([
"R2.1 constructor-function",
]);
expect(markers("const util = require('util'); util.inherits(A, B);")).toEqual([
"R2.1 constructor-function",
]);
expect(markers("function Child() { Parent.call(this, 1); }")).toEqual([
"R2.1 constructor-function",
]);
expect(markers("class C {} const c = new C();")).toEqual([]);
expect(
markers("function wrap(fn) { return function () { return fn.apply(this, arguments); }; }"),
).toEqual([]);
});
test("R2.2 prototype mutation", () => {
expect(markers("Object.setPrototypeOf(a, b);")).toEqual(["R2.2 prototype-write"]);
expect(markers("o.__proto__ = p;")).toEqual(["R2.2 prototype-write"]);
expect(markers("const o = { __proto__: base };")).toEqual(["R2.2 prototype-write"]);
// The transpiled-Error idiom and null-prototype dictionaries stay.
expect(
markers(
"class E extends Error { constructor() { super(); Object.setPrototypeOf(this, E.prototype); } }",
),
).toEqual([]);
expect(markers("const d = { __proto__: null };")).toEqual([]);
});
test("R2.3 shape mutation outside the checker's view", () => {
expect(markers("Object.defineProperty(o, 'k', { get() { return 1; } });")).toEqual([
"R2.3 define-property",
]);
expect(markers("class C {} C.prototype.extra = 1;")).toEqual(["R2.3 define-property"]);
// Module objects and interop helpers build module records.
expect(markers("Object.defineProperty(exports, '__esModule', { value: true });")).toEqual([]);
expect(
markers(
"var __createBinding = function (o, m, k) { Object.defineProperty(o, k, { enumerable: true, get: function () { return m[k]; } }); };",
),
).toEqual([]);
});
test("R2.4 sloppy-mode semantics", () => {
expect(markers("with (o) { x; }")).toEqual(["R2.4 with"]);
expect(markers("function f(a) { arguments[0] = 1; return a; }")).toEqual([
"R2.4 sloppy-arguments",
]);
expect(markers("function f(a) { a = 2; return arguments[0]; }")).toEqual([
"R2.4 sloppy-arguments",
]);
expect(markers("function f() { return arguments.callee; }")).toEqual(["R2.4 sloppy-arguments"]);
expect(markers("var n = 010;")).toEqual(["R2.4 sloppy-mode"]);
expect(markers("var x = 1; delete x;")).toEqual(["R2.4 sloppy-mode"]);
// Strict code: a prologue, an ES module, a class body.
expect(markers("'use strict'; function f(a) { a = 2; return arguments[0]; }")).toEqual([]);
expect(markers("export function f(a) { a = 2; return arguments[0]; }", "mjs")).toEqual([]);
expect(markers("class C { m(a) { a = 2; return arguments[0]; } }")).toEqual([]);
});
test("R2.5 builtin and global patching", () => {
expect(markers("Array.prototype.last = function () {};")).toEqual(["R2.5 builtin-write"]);
expect(markers("Promise = Bluebird;")).toEqual(["R2.5 builtin-write"]);
expect(markers("globalThis.fetch = f;")).toEqual(["R2.5 builtin-write"]);
expect(markers("const fs = require('fs'); fs.close = patched;")).toEqual([
"R2.5 builtin-write",
]);
expect(markers("const p = new Proxy(t, h);")).toEqual(["R2.5 proxy"]);
// Modeled process state and tuning knobs stay; so do redeclared names.
expect(
markers("process.exitCode = 1; process.env.X = 'y'; Error.stackTraceLimit = 50;"),
).toEqual([]);
expect(markers("var Promise = require('bluebird'); Promise.config = {};")).toEqual([]);
});
});
test("markers come back in source order with their locations", () => {
const source = "Object.setPrototypeOf(a, b);\neval(x);\n";
const sf = parseSourceFile(`/virtual/order${serial++}.cjs`, source, "js");
const found = scanModule(sf);
expect(found.map((m) => [m.rule, source.slice(m.loc.start, m.loc.end)])).toEqual([
["R2.2", "Object.setPrototypeOf(a, b)"],
["R1.1", "eval(x)"],
]);
});
test("computed writes keep the member they write through", () => {
expect(markers("process.env[key] = value;")).toEqual([]);
expect(markers("Array.prototype[name] = fn;")).toEqual(["R2.5 builtin-write"]);
});
@@ -0,0 +1,849 @@
/* The pre-scan (DESIGN.md §1.4, phase 1): a syntactic walk over one
* JavaScript module that finds the R1 (runtime) and R2 (object-model)
* markers of rules.ts. It is a fast path and a source of good reasons; it
* never decides alone that a module is native, and the authoritative signal
* stays the lowering's refusals (classify.ts).
*
* Every check is local to the file: a name is a "function" when the file
* declares it as a function, a builtin global when the file does not
* declare it at all, and a builtin module object when the file binds it to
* require("<builtin>") or imports it from one. Checker-dependent parts of
* R2.3 (writes the declared type does not cover, freeze followed by writes)
* and R2.4 (assignments to undeclared identifiers, `this` as the global
* object) are left to the lowering. */
import type { SrcLoc } from "../ir/ir.js";
import * as ts from "../frontend/ts7/adapter.js";
import { SUPPORTED_BUILTIN_MODULES } from "../frontend/builtin-modules.js";
import type { NodeIslandRule } from "./rules.js";
export interface NodeIslandMarker {
rule: NodeIslandRule;
/** The reason id (rules.ts). */
reason: string;
/** The construct, as a short phrase for messages. */
construct: string;
loc: SrcLoc;
}
/** Markers past this count in one module add nothing to a report. */
const MARKER_CAP = 64;
const BUILTIN_GLOBALS = new Set([
"Object",
"Array",
"String",
"Number",
"Boolean",
"Function",
"Symbol",
"BigInt",
"Promise",
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"ReferenceError",
"EvalError",
"URIError",
"AggregateError",
"Map",
"Set",
"WeakMap",
"WeakSet",
"RegExp",
"Date",
"Math",
"JSON",
"Reflect",
"Intl",
"ArrayBuffer",
"SharedArrayBuffer",
"DataView",
"Uint8Array",
"Int8Array",
"Uint16Array",
"Int16Array",
"Uint32Array",
"Int32Array",
"Float32Array",
"Float64Array",
"BigInt64Array",
"BigUint64Array",
"Uint8ClampedArray",
"Buffer",
"process",
"console",
"globalThis",
"global",
"URL",
"URLSearchParams",
"TextEncoder",
"TextDecoder",
"AbortController",
"AbortSignal",
"EventTarget",
"Event",
"fetch",
"setTimeout",
"setInterval",
"setImmediate",
"clearTimeout",
"clearInterval",
"clearImmediate",
"queueMicrotask",
"structuredClone",
]);
/** Writes to builtin members that the native subset models (process
* state, tuning knobs). */
const ALLOWED_BUILTIN_WRITES = new Set([
"process.exitCode",
"process.title",
"process.env",
"Error.stackTraceLimit",
"Buffer.poolSize",
"events.defaultMaxListeners",
"events.EventEmitter.defaultMaxListeners",
"EventEmitter.defaultMaxListeners",
]);
const ADDON_LOADERS = new Set([
"bindings",
"node-gyp-build",
"node-pre-gyp",
"@mapbox/node-pre-gyp",
"prebuild-install",
"node-gyp-build-optional-packages",
]);
const LOADER_HOOK_PACKAGES = new Set(["import-in-the-middle", "require-in-the-middle"]);
function builtinModuleName(spec: string): string | null {
const bare = spec.startsWith("node:") ? spec.slice(5) : spec;
if ((SUPPORTED_BUILTIN_MODULES as readonly string[]).includes(bare)) return bare;
if (bare === "vm" || bare === "v8" || bare === "module" || bare === "inspector") return bare;
return null;
}
function literalText(node: ts.Node | undefined): string | null {
if (node === undefined) return null;
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
return null;
}
/** A specifier that folds at build time: a literal, or a concatenation of
* literals. */
function isFoldableSpecifier(node: ts.Node): boolean {
if (literalText(node) !== null) return true;
if (ts.isParenthesizedExpression(node)) return isFoldableSpecifier(node.expression);
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
return isFoldableSpecifier(node.left) && isFoldableSpecifier(node.right);
return false;
}
function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
function propertyName(node: ts.Node): string | null {
if (ts.isPropertyAccessExpression(node)) {
const name = node.name;
return ts.isIdentifier(name) ? name.text : null;
}
if (ts.isElementAccessExpression(node)) return literalText(node.argumentExpression);
return null;
}
/** `a.b.c` as "a.b.c" (element accesses with literal keys included), or
* null when the chain has a computed key or does not start at a name. */
function chainText(node: ts.Node): string | null {
if (ts.isIdentifier(node)) return node.text;
if (node.kind === ts.SyntaxKind.ThisKeyword) return "this";
if (ts.isParenthesizedExpression(node)) return chainText(node.expression);
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const base = chainText(node.expression);
const name = propertyName(node);
if (base === null || name === null) return null;
return `${base}.${name}`;
}
return null;
}
/** Like chainText, with computed keys spelled `[…]`: `process.env[…]`. */
function memberText(node: ts.Node): string | null {
const text = chainText(node);
if (text !== null) return text;
if (ts.isElementAccessExpression(node)) {
const base = memberText(node.expression);
return base === null ? null : `${base}[…]`;
}
if (ts.isPropertyAccessExpression(node)) {
const base = memberText(node.expression);
const name = propertyName(node);
return base === null || name === null ? null : `${base}.${name}`;
}
return null;
}
/** The identifier a member chain starts at (`a` of `a.b[c].d`). */
function chainRoot(node: ts.Node): ts.Identifier | null {
let cur = node;
for (;;) {
if (ts.isIdentifier(cur)) return cur;
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) {
cur = cur.expression;
continue;
}
if (ts.isParenthesizedExpression(cur)) {
cur = cur.expression;
continue;
}
return null;
}
}
function hasUseStrict(statements: readonly ts.Node[]): boolean {
for (const stmt of statements) {
if (!ts.isExpressionStatement(stmt)) return false;
const text = literalText(stmt.expression);
if (text === null) return false;
if (text === "use strict") return true;
}
return false;
}
/** ES module syntax at the top level (static import or export): the
* module is strict. The parser's module indicator is not used, since it
* also marks CommonJS files under forced module detection. */
function hasEsmSyntax(sf: ts.SourceFile): boolean {
for (const stmt of sf.statements.nodes) {
if (ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) return true;
if (ts.isExportAssignment(stmt)) return true;
const head = sf.text.slice(ts.tokenStartOfNode(stmt), ts.tokenStartOfNode(stmt) + 7);
if (head === "export ") return true;
}
return false;
}
function isFunctionBoundary(node: ts.Node): boolean {
return (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
ts.isGetAccessorDeclaration(node) ||
ts.isSetAccessorDeclaration(node)
);
}
/** One module's pre-scan. */
class ModuleScanner {
readonly markers: NodeIslandMarker[] = [];
/** Every name the file declares (any scope): a builtin global the file
* redeclares is not the builtin. */
private readonly declared = new Set<string>();
/** Names declared as functions (declarations and function-valued
* variables). */
private readonly functions = new Set<string>();
private readonly classes = new Set<string>();
/** Local name → the builtin module (or `module.member`) it is bound to. */
private readonly builtinBindings = new Map<string, string>();
/** Local names bound to a createRequire(...) result. */
private readonly createdRequires = new Set<string>();
private readonly strictness = new Map<ts.Node, boolean>();
private readonly moduleStrict: boolean;
constructor(private readonly sf: ts.SourceFile) {
const name = sf.fileName;
this.moduleStrict =
name.endsWith(".mjs") ||
name.endsWith(".mts") ||
hasEsmSyntax(sf) ||
hasUseStrict(sf.statements.nodes);
}
scan(): NodeIslandMarker[] {
this.collectDeclarations();
ts.walkPreorder(this.sf, (node) => {
if (this.markers.length >= MARKER_CAP) return "stop";
this.visit(node);
return undefined;
});
return this.markers;
}
private add(rule: NodeIslandRule, reason: string, construct: string, node: ts.Node): void {
if (this.markers.length >= MARKER_CAP) return;
this.markers.push({
rule,
reason,
construct,
loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end },
});
}
private bindName(name: ts.Node | undefined, init: ts.Node | undefined): void {
if (name === undefined) return;
if (ts.isIdentifier(name)) {
this.declared.add(name.text);
if (init === undefined) return;
let value = init;
while (ts.isParenthesizedExpression(value)) value = value.expression;
if (ts.isFunctionExpression(value) || ts.isArrowFunction(value))
this.functions.add(name.text);
else if (ts.isClassExpression(value)) this.classes.add(name.text);
const bound = this.builtinOf(value);
if (bound !== null) this.builtinBindings.set(name.text, bound);
if (ts.isCallExpression(value) && this.isCreateRequireCallee(value.expression))
this.createdRequires.add(name.text);
return;
}
if (ts.isObjectBindingPattern(name)) {
const base = init === undefined ? null : this.builtinOf(init);
for (const element of name.elements.nodes) {
if (!ts.isBindingElement(element)) continue;
const local = element.name;
if (local !== undefined && ts.isIdentifier(local) && base !== null) {
const key = element.propertyName;
const member = key !== undefined && ts.isIdentifier(key) ? key.text : local.text;
this.builtinBindings.set(local.text, `${base}.${member}`);
}
this.bindName(local, undefined);
}
return;
}
if (ts.isArrayBindingPattern(name)) {
for (const element of name.elements.nodes) {
if (ts.isBindingElement(element)) this.bindName(element.name, undefined);
}
}
}
/** `require("fs")`, `require("node:util").inherits` → "fs", "util.inherits". */
private builtinOf(node: ts.Node): string | null {
let value = node;
while (ts.isParenthesizedExpression(value)) value = value.expression;
if (ts.isCallExpression(value) && this.isRequireCallee(value.expression)) {
const spec = literalText(value.arguments.nodes[0]);
return spec === null ? null : builtinModuleName(spec);
}
if (ts.isPropertyAccessExpression(value)) {
const base = this.builtinOf(value.expression);
const name = propertyName(value);
return base === null || name === null ? null : `${base}.${name}`;
}
if (ts.isIdentifier(value)) return this.builtinBindings.get(value.text) ?? null;
return null;
}
private isRequireCallee(node: ts.Node): boolean {
return ts.isIdentifier(node) && node.text === "require";
}
private isCreateRequireCallee(node: ts.Node): boolean {
if (ts.isIdentifier(node)) {
return (
node.text === "createRequire" ||
this.builtinBindings.get(node.text) === "module.createRequire"
);
}
return propertyName(node) === "createRequire";
}
private collectDeclarations(): void {
ts.walkPreorder(this.sf, (node) => {
if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
this.declared.add(node.name.text);
this.functions.add(node.name.text);
} else if (ts.isClassDeclaration(node) && node.name !== undefined) {
this.declared.add(node.name.text);
this.classes.add(node.name.text);
} else if (ts.isVariableDeclaration(node)) {
this.bindName(node.name, node.initializer);
} else if (ts.isParameter(node)) {
this.bindName(node.name, undefined);
} else if (ts.isImportDeclaration(node)) {
const spec = literalText(node.moduleSpecifier);
const module = spec === null ? null : builtinModuleName(spec);
const clause = node.importClause;
if (clause !== undefined) {
if (clause.name !== undefined) {
this.declared.add(clause.name.text);
if (module !== null) this.builtinBindings.set(clause.name.text, module);
}
const bindings = clause.namedBindings;
if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
this.declared.add(bindings.name.text);
if (module !== null) this.builtinBindings.set(bindings.name.text, module);
} else if (bindings !== undefined && ts.isNamedImports(bindings)) {
for (const element of bindings.elements.nodes) {
this.declared.add(element.name.text);
const imported = element.propertyName;
const member =
imported !== undefined && ts.isIdentifier(imported)
? imported.text
: element.name.text;
if (module !== null)
this.builtinBindings.set(element.name.text, `${module}.${member}`);
}
}
}
}
return undefined;
});
}
/** Whether the code at `node` runs in strict mode. */
private isStrict(node: ts.Node): boolean {
if (this.moduleStrict) return true;
let cur: ts.Node | undefined = node.parent;
const visited: ts.Node[] = [];
let strict = false;
while (cur !== undefined) {
const known = this.strictness.get(cur);
if (known !== undefined) {
strict = known;
break;
}
visited.push(cur);
if (ts.isClassDeclaration(cur) || ts.isClassExpression(cur)) {
strict = true;
break;
}
if (isFunctionBoundary(cur) || ts.isArrowFunction(cur)) {
const body = (cur as ts.FunctionLikeDeclaration).body;
if (body !== undefined && ts.isBlock(body) && hasUseStrict(body.statements.nodes)) {
strict = true;
break;
}
}
cur = cur.parent;
}
for (const n of visited) this.strictness.set(n, strict);
return strict;
}
private isGlobal(name: string): boolean {
return BUILTIN_GLOBALS.has(name) && !this.declared.has(name);
}
private visit(node: ts.Node): void {
if (ts.isCallExpression(node)) this.visitCall(node);
else if (ts.isNewExpression(node)) this.visitNew(node);
else if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind))
this.visitWrite(node.left, node, "write");
else if (ts.isDeleteExpression(node)) this.visitDelete(node);
else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
if (
node.operator === ts.SyntaxKind.PlusPlusToken ||
node.operator === ts.SyntaxKind.MinusMinusToken
)
this.visitWrite(node.operand, node, "write");
} else if (ts.isWithStatement(node)) {
this.add("R2.4", "with", "a 'with' statement", node);
} else if (ts.isImportDeclaration(node)) {
const spec = literalText(node.moduleSpecifier);
if (spec !== null) this.visitSpecifier(spec, node);
} else if (ts.isIdentifier(node)) {
this.visitIdentifier(node);
} else if (ts.isNumericLiteral(node)) {
const text = this.sf.text.slice(ts.tokenStartOfNode(node), node.end);
if (/^0[0-9]+$/.test(text) && !this.isStrict(node))
this.add("R2.4", "sloppy-mode", "a legacy octal literal", node);
} else if (ts.isPropertyAccessExpression(node)) {
const text = chainText(node);
if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node))
this.add("R2.4", "sloppy-arguments", `'${text}'`, node);
} else if (ts.isObjectLiteralExpression(node)) {
for (const prop of node.properties.nodes) {
if (!ts.isPropertyAssignment(prop)) continue;
const name = prop.name;
const key = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null;
if (key === "__proto__" && prop.initializer.kind !== ts.SyntaxKind.NullKeyword)
this.add("R2.2", "prototype-write", "an object literal with a '__proto__' value", prop);
}
} else if (isFunctionBoundary(node)) {
this.visitFunction(node as ts.FunctionLikeDeclaration);
}
}
private visitSpecifier(spec: string, node: ts.Node): void {
const builtin = builtinModuleName(spec);
if (builtin === "vm") this.add("R1.1", "vm", "the 'vm' module", node);
else if (spec.endsWith(".node"))
this.add("R1.3", "native-addon", `the native addon '${spec}'`, node);
else if (ADDON_LOADERS.has(spec))
this.add("R1.3", "native-addon", `the native addon loader '${spec}'`, node);
else if (LOADER_HOOK_PACKAGES.has(spec))
this.add("R1.4", "loader-hook", `the loader hook package '${spec}'`, node);
}
private visitCall(call: ts.CallExpression): void {
const callee = call.expression;
const args = call.arguments.nodes;
const first = args[0];
// import(e) and require(e)
if (callee.kind === ts.SyntaxKind.ImportKeyword) {
if (first !== undefined && !isFoldableSpecifier(first))
this.add("R1.2", "dynamic-import", "import() with a computed specifier", call);
else if (first !== undefined) this.visitSpecifier(literalText(first) ?? "", call);
return;
}
if (
this.isRequireCallee(callee) ||
(ts.isIdentifier(callee) && this.createdRequires.has(callee.text))
) {
if (first !== undefined && !isFoldableSpecifier(first))
this.add("R1.2", "dynamic-require", "require() with a computed specifier", call);
else if (first !== undefined) this.visitSpecifier(literalText(first) ?? "", call);
return;
}
if (ts.isCallExpression(callee) && this.isCreateRequireCallee(callee.expression)) {
if (first !== undefined && !isFoldableSpecifier(first))
this.add("R1.2", "dynamic-require", "createRequire(...)() with a computed specifier", call);
return;
}
// eval, Function
let target = callee;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.CommaToken)
target = target.right;
const text = chainText(target);
if (text === null) return;
if (
(text === "eval" && !this.declared.has("eval")) ||
text === "globalThis.eval" ||
text === "global.eval" ||
text === "window.eval"
) {
this.add("R1.1", "code-from-string", "eval()", call);
return;
}
if (text === "Function" && this.isGlobal("Function")) {
this.visitFunctionConstructor(call, args);
return;
}
const root = chainRoot(target);
const rootBinding = root === null ? undefined : this.builtinBindings.get(root.text);
const resolved =
rootBinding !== undefined && root !== null
? rootBinding + text.slice(root.text.length)
: text;
// engine internals
if (
(text === "process.binding" || text === "process._linkedBinding") &&
this.isGlobal("process")
) {
this.add("R1.5", "engine-internal", `'${text}()'`, call);
return;
}
if (text === "internalBinding" && !this.declared.has("internalBinding")) {
this.add("R1.5", "engine-internal", "'internalBinding()'", call);
return;
}
if (resolved === "v8.setFlagsFromString" || text.endsWith(".setFlagsFromString")) {
this.add("R1.5", "engine-internal", "'v8.setFlagsFromString()'", call);
return;
}
if ((text === "global.gc" || text === "globalThis.gc") && this.isGlobal(text.split(".")[0]!)) {
this.add("R1.5", "engine-internal", `'${text}()'`, call);
return;
}
if (text === "process.dlopen" && this.isGlobal("process")) {
this.add("R1.3", "native-addon", "'process.dlopen()'", call);
return;
}
// loader hooks
if (resolved === "module.register" || resolved === "module.registerHooks") {
this.add("R1.4", "loader-hook", `'${resolved}()'`, call);
return;
}
// prototype mutation
if (
(text === "Object.setPrototypeOf" && this.isGlobal("Object")) ||
(text === "Reflect.setPrototypeOf" && this.isGlobal("Reflect"))
) {
// The transpiled-Error idiom `Object.setPrototypeOf(this, …)` stays.
if (first !== undefined && first.kind !== ts.SyntaxKind.ThisKeyword)
this.add("R2.2", "prototype-write", `'${text}()'`, call);
return;
}
// defineProperty outside the module object and interop helpers
if (
((text === "Object.defineProperty" || text === "Object.defineProperties") &&
this.isGlobal("Object")) ||
(text === "Reflect.defineProperty" && this.isGlobal("Reflect"))
) {
if (first !== undefined && !this.isModuleObject(first) && !this.inInteropHelper(call))
this.add("R2.3", "define-property", `'${text}()'`, call);
return;
}
if (text.endsWith(".__defineGetter__") || text.endsWith(".__defineSetter__")) {
this.add("R2.3", "define-property", `'${text.slice(text.lastIndexOf(".") + 1)}()'`, call);
return;
}
// constructor functions
if (
args.length === 2 &&
(resolved === "util.inherits" ||
(text === "inherits" && !this.functions.has("inherits")) ||
text.endsWith(".inherits"))
) {
this.add("R2.1", "constructor-function", `'${text}()'`, call);
return;
}
if (
ts.isPropertyAccessExpression(target) &&
(propertyName(target) === "call" || propertyName(target) === "apply") &&
first !== undefined &&
first.kind === ts.SyntaxKind.ThisKeyword &&
ts.isExpressionStatement(call.parent!)
) {
const fn = target.expression;
const name = ts.isIdentifier(fn) ? fn.text : null;
if (name !== null && /^[A-Z]/.test(name) && !this.classes.has(name))
this.add(
"R2.1",
"constructor-function",
`'${name}.${propertyName(target)}(this, …)'`,
call,
);
}
}
private visitFunctionConstructor(node: ts.Node, args: readonly ts.Node[]): void {
// The global-object idiom Function("return this")() folds to globalThis.
if (args.length === 1) {
const body = literalText(args[0]);
if (body !== null && body.trim() === "return this") return;
}
// A capability probe tested inside try (zod 4's allowsEval) keeps
// today's EvalError lowering.
for (let cur = node.parent; cur !== undefined; cur = cur.parent) {
if (isFunctionBoundary(cur) || ts.isArrowFunction(cur)) break;
if (ts.isBlock(cur) && cur.parent !== undefined && ts.isTryStatement(cur.parent)) {
if (cur.parent.tryBlock === cur) return;
}
}
this.add("R1.1", "code-from-string", "new Function()", node);
}
private visitNew(node: ts.NewExpression): void {
const callee = node.expression;
if (!ts.isIdentifier(callee)) return;
const name = callee.text;
if (name === "Function" && this.isGlobal("Function")) {
this.visitFunctionConstructor(node, node.arguments?.nodes ?? []);
} else if (name === "Proxy" && !this.declared.has("Proxy")) {
this.add("R2.5", "proxy", "new Proxy()", node);
} else if (this.functions.has(name) && !this.classes.has(name)) {
this.add("R2.1", "constructor-function", `'new ${name}()' of a function`, node);
}
}
private isModuleObject(node: ts.Node): boolean {
const text = chainText(node);
return text === "exports" || text === "module.exports" || text === "module";
}
/** Inside a module-interop helper (tsc's __createBinding/__exportStar,
* Babel's _interopRequireWildcard, esbuild's __export): their
* defineProperty calls build module objects the native subset models. */
private inInteropHelper(node: ts.Node): boolean {
for (let cur = node.parent; cur !== undefined; cur = cur.parent) {
let name: string | null = null;
if (ts.isFunctionDeclaration(cur) && cur.name !== undefined) name = cur.name.text;
else if (ts.isVariableDeclaration(cur) && ts.isIdentifier(cur.name)) name = cur.name.text;
else if (ts.isBinaryExpression(cur) && isAssignmentOperator(cur.operatorToken.kind))
name = chainText(cur.left);
if (name !== null && /(^|\.)_/.test(name)) return true;
}
return false;
}
private visitWrite(target: ts.Node, node: ts.Node, what: "write" | "delete"): void {
let left = target;
while (ts.isParenthesizedExpression(left)) left = left.expression;
if (ts.isIdentifier(left)) {
if (this.isGlobal(left.text) && left.text !== "global" && left.text !== "globalThis")
this.add("R2.5", "builtin-write", `a ${what} of the global '${left.text}'`, node);
return;
}
if (!ts.isPropertyAccessExpression(left) && !ts.isElementAccessExpression(left)) return;
const text = chainText(left);
const root = chainRoot(left);
const base = left.expression;
const baseText = chainText(base);
// require.extensions / require.cache
if (
text !== null &&
(text.startsWith("require.extensions") || text.startsWith("require.cache"))
) {
this.add(
"R1.4",
"loader-hook",
`a ${what} of '${text.startsWith("require.cache") ? "require.cache" : "require.extensions"}'`,
node,
);
return;
}
if (baseText === "require.cache" || baseText === "require.extensions") {
this.add("R1.4", "loader-hook", `a ${what} of '${baseText}'`, node);
return;
}
// __proto__ writes
if (propertyName(left) === "__proto__") {
this.add("R2.2", "prototype-write", `a ${what} of '__proto__'`, node);
return;
}
if (root === null) return;
const binding = this.builtinBindings.get(root.text);
// Module internals: Module._load = …, Module.prototype.require = …
if (text !== null) {
const resolved = binding !== undefined ? binding + text.slice(root.text.length) : text;
if (
/^(module|Module)\.(_load|_resolveFilename|_extensions|wrap|_compile|prototype\.require|prototype\._compile)$/.test(
resolved,
) &&
(binding !== undefined || !this.declared.has(root.text) || root.text === "Module")
) {
this.add("R1.4", "loader-hook", `a ${what} of '${resolved}'`, node);
return;
}
// F.prototype…
const parts = text.split(".");
if (parts.length >= 2 && parts[1] === "prototype") {
const name = parts[0]!;
if (this.isGlobal(name)) {
this.add("R2.5", "builtin-write", `a ${what} of '${text}'`, node);
return;
}
if (binding === undefined && this.classes.has(name) && !this.functions.has(name)) {
this.add("R2.3", "define-property", `a ${what} of the class prototype '${text}'`, node);
return;
}
if (binding === undefined && name !== "this") {
this.add("R2.1", "constructor-function", `a ${what} of '${text}'`, node);
return;
}
}
}
// Builtin globals and builtin module objects.
const spelled = memberText(left) ?? `${root.text}[…]`;
if (binding !== undefined) {
const member = binding + spelled.slice(root.text.length);
if (!this.allowedWrite(member)) {
this.add(
"R2.5",
"builtin-write",
`a ${what} of the builtin module member '${member}'`,
node,
);
}
return;
}
if (this.isGlobal(root.text)) {
const member = spelled;
if (!this.allowedWrite(member))
this.add("R2.5", "builtin-write", `a ${what} of the builtin '${member}'`, node);
}
}
private allowedWrite(member: string): boolean {
for (const allowed of ALLOWED_BUILTIN_WRITES) {
if (
member === allowed ||
member.startsWith(`${allowed}.`) ||
member.startsWith(`${allowed}[`)
)
return true;
}
return false;
}
private visitDelete(node: ts.DeleteExpression): void {
let target = node.expression;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (ts.isIdentifier(target)) {
if (!this.isStrict(node))
this.add("R2.4", "sloppy-mode", `'delete ${target.text}' of an identifier`, node);
return;
}
this.visitWrite(target, node, "delete");
}
private visitIdentifier(node: ts.Identifier): void {
const name = node.text;
if (name !== "WeakRef" && name !== "FinalizationRegistry") return;
if (this.declared.has(name)) return;
const parent = node.parent;
if (parent === undefined) return;
// Only value uses: not a property name, declaration name or typeof probe.
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return;
if (ts.isTypeOfExpression(parent)) return;
if ((ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent)) && parent.name === node)
return;
this.add("R1.6", "weak-ref", `'${name}'`, node);
}
/** Observable `arguments` aliasing in a sloppy function: writes to
* arguments[i], or to a parameter while `arguments` is read. */
private visitFunction(fn: ts.FunctionLikeDeclaration): void {
const body = fn.body;
if (body === undefined || !ts.isBlock(body)) return;
if (this.isStrict(body.statements.nodes[0] ?? body)) return;
if (hasUseStrict(body.statements.nodes)) return;
const params = new Set<string>();
for (const p of fn.parameters.nodes) {
if (ts.isIdentifier(p.name)) params.add(p.name.text);
}
const seen: ArgumentsUse = { reads: false, argumentsWrite: null, paramWrite: null };
ts.walkPreorder(body, (node) => {
if (
node !== body &&
(isFunctionBoundary(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node))
)
return "skip";
if (ts.isIdentifier(node) && node.text === "arguments") seen.reads = true;
if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
const left = node.left;
if (
ts.isElementAccessExpression(left) &&
ts.isIdentifier(left.expression) &&
left.expression.text === "arguments"
) {
if (seen.argumentsWrite === null) seen.argumentsWrite = node;
} else if (ts.isIdentifier(left) && params.has(left.text)) {
if (seen.paramWrite === null) seen.paramWrite = node;
}
}
return undefined;
});
if (seen.argumentsWrite !== null)
this.add(
"R2.4",
"sloppy-arguments",
"a write to 'arguments[i]' in sloppy mode",
seen.argumentsWrite,
);
else if (seen.reads && seen.paramWrite !== null)
this.add(
"R2.4",
"sloppy-arguments",
"a parameter write while 'arguments' is read in sloppy mode",
seen.paramWrite,
);
}
}
interface ArgumentsUse {
reads: boolean;
argumentsWrite: ts.Node | null;
paramWrite: ts.Node | null;
}
/** The R1/R2 markers of one JavaScript module, in source order. */
export function scanModule(sf: ts.SourceFile): NodeIslandMarker[] {
const markers = new ModuleScanner(sf).scan();
return markers.sort((a, b) => a.loc.start - b.loc.start);
}
@@ -0,0 +1,68 @@
/* Where a source file comes from. User code (the program's own tree and
* workspace packages compiled from source) is strict; npm code (installed
* packages under node_modules) is lenient and is decided per package
* instance; shims are scriptc's own registry implementations; builtin
* covers declaration files of the type world. */
import { join } from "node:path";
import { isPackageShimFile } from "../frontend/package-shims.js";
import { trackedReadFile } from "../frontend/input-tracker.js";
export type Origin = "user" | "npm" | "shim" | "builtin";
export function originOf(file: string): Origin {
const path = file.split("\\").join("/");
if (isPackageShimFile(path)) return "shim";
if (/\.d\.[cm]?ts$/.test(path)) return "builtin";
return path.includes("/node_modules/") ? "npm" : "user";
}
/** One installed package: the decision unit for npm code. pnpm keeps
* versions apart in different directories, so the directory identifies the
* instance. */
export interface PackageInstance {
name: string;
/** The package directory (…/node_modules/<name>). */
dir: string;
version: string | null;
}
/** The package instance of a file under node_modules: the LAST
* node_modules segment (nested installs belong to the innermost package),
* scope-aware. Null outside node_modules. */
export function packageDirOf(file: string): { name: string; dir: string } | null {
const path = file.split("\\").join("/");
const parts = path.split("/");
const i = parts.lastIndexOf("node_modules");
if (i < 0 || i + 1 >= parts.length - 1) return null;
const first = parts[i + 1]!;
if (first.startsWith("@")) {
if (i + 2 >= parts.length - 1) return null;
const name = `${first}/${parts[i + 2]!}`;
return { name, dir: parts.slice(0, i + 3).join("/") };
}
return { name: first, dir: parts.slice(0, i + 2).join("/") };
}
/** The `version` field of a package directory's package.json. */
export function packageVersionOf(dir: string): string | null {
let version: string | null = null;
const text = trackedReadFile(join(dir, "package.json"));
if (text !== null) {
try {
const parsed: unknown = JSON.parse(text);
if (parsed !== null && typeof parsed === "object") {
const v = (parsed as Record<string, unknown>)["version"];
if (typeof v === "string") version = v;
}
} catch {
version = null;
}
}
return version;
}
export function packageInstanceOf(file: string): PackageInstance | null {
const at = packageDirOf(file);
if (at === null) return null;
return { name: at.name, dir: at.dir, version: packageVersionOf(at.dir) };
}
@@ -0,0 +1,167 @@
/* The Node island policy (DESIGN.md §1.5–§1.6): user code strict, npm
* lenient. npm code outside the native subset becomes a Node island
* automatically (`nodeIslands.auto`, `--node-islands=auto`); user modules
* become islands only when marked (`nodeIslands.modules`,
* `--node-island=<path>`); `"native"` (`--native=<pkg>`) 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. */
import { isAbsolute, join, resolve, dirname } from "node:path";
import type { ProjectConfig } from "../project-config.js";
export type NodeIslandsMode = "auto" | "explicit" | "off";
export const NODE_ISLANDS_MODES: readonly NodeIslandsMode[] = ["auto", "explicit", "off"];
/** The command-line half of the policy (`build`, `run` and `coverage`
* accept these). */
export interface NodeIslandOptions {
/** `--node-islands=auto|explicit|off`. */
mode?: NodeIslandsMode;
/** `--node-island=<pkg-or-path>`: packages and user modules forced into a
* Node island. Paths are absolute. */
islands?: readonly string[];
/** `--native=<pkg>`: packages that must compile natively. */
native?: readonly string[];
/** `--node=<path>`: the node executable recorded for islands. */
node?: string;
/** `--strict-user-js`: report R2 constructs in user JavaScript as
* SC8101–SC8105. */
strictUserJs?: boolean;
}
export type DecisionSource = "config" | "cli";
export interface UserModulePattern {
/** An absolute path or glob. */
pattern: string;
source: DecisionSource;
}
export interface NodeIslandPolicy {
mode: NodeIslandsMode;
/** Where the mode came from (an explicit `"auto": false` reads as
* "explicit"). */
modeSource: DecisionSource | "default";
forcedNode: Map<string, DecisionSource>;
forcedNative: Map<string, DecisionSource>;
/** `"unloaded"` packages: never loaded, never islands. */
unloaded: Set<string>;
userModules: UserModulePattern[];
node: string | null;
strictUserJs: boolean;
}
/** Whether a `--node-island` value names a module rather than a package. */
export function isModuleIslandArgument(value: string): boolean {
return (
value.startsWith(".") ||
value.startsWith("/") ||
/\.(?:[cm]?[jt]s|[jt]sx)$/.test(value) ||
value.includes("*")
);
}
export function nodeIslandPolicy(
config: ProjectConfig,
options: NodeIslandOptions = {},
): NodeIslandPolicy {
const forcedNode = new Map<string, DecisionSource>();
const forcedNative = new Map<string, DecisionSource>();
const unloaded = new Set<string>();
for (const [name, setting] of config.packages) {
if (setting.unloaded) unloaded.add(name);
else if (setting.run === "node") forcedNode.set(name, "config");
else if (setting.run === "native") forcedNative.set(name, "config");
}
const userModules: UserModulePattern[] = [];
const base = config.manifest === null ? process.cwd() : dirname(config.manifest);
for (const pattern of config.nodeIslands.modules) {
userModules.push({
pattern: isAbsolute(pattern) ? pattern : join(base, pattern),
source: "config",
});
}
for (const value of options.islands ?? []) {
if (isModuleIslandArgument(value)) {
userModules.push({ pattern: resolve(value), source: "cli" });
} else {
forcedNative.delete(value);
forcedNode.set(value, "cli");
}
}
for (const name of options.native ?? []) {
forcedNode.delete(name);
forcedNative.set(name, "cli");
}
let mode: NodeIslandsMode = "auto";
let modeSource: DecisionSource | "default" = "default";
if (options.mode !== undefined) {
mode = options.mode;
modeSource = "cli";
} else if (!config.nodeIslands.auto) {
mode = "explicit";
modeSource = "config";
}
return {
mode,
modeSource,
forcedNode,
forcedNative,
unloaded,
userModules,
node: options.node ?? config.nodeIslands.node,
strictUserJs: options.strictUserJs === true,
};
}
/** Whether a user module is marked to run in a Node island. */
export function isMarkedUserModule(policy: NodeIslandPolicy, file: string): DecisionSource | null {
const path = file.split("\\").join("/");
for (const entry of policy.userModules) {
if (globMatch(entry.pattern.split("\\").join("/"), path)) return entry.source;
}
return null;
}
/** Glob matching over `/`-separated paths: `**` spans directories, `*` and
* `?` stay inside one segment. */
export function globMatch(pattern: string, path: string): boolean {
return matchSegments(pattern.split("/"), 0, path.split("/"), 0);
}
function matchSegments(pat: string[], pi: number, segs: string[], si: number): boolean {
if (pi === pat.length) return si === segs.length;
const p = pat[pi]!;
if (p === "**") {
for (let k = si; k <= segs.length; k++) if (matchSegments(pat, pi + 1, segs, k)) return true;
return false;
}
if (si === segs.length) return false;
return matchSegment(p, 0, segs[si]!, 0) && matchSegments(pat, pi + 1, segs, si + 1);
}
function matchSegment(p: string, pi: number, s: string, si: number): boolean {
if (pi === p.length) return si === s.length;
const ch = p.charAt(pi);
if (ch === "*") {
for (let k = si; k <= s.length; k++) if (matchSegment(p, pi + 1, s, k)) return true;
return false;
}
if (si === s.length) return false;
if (ch === "?" || ch === s.charAt(si)) return matchSegment(p, pi + 1, s, si + 1);
return false;
}
/** The policy's identity for caches: two analyses with the same identity
* decide the same islands. */
export function nodeIslandOptionsKey(options: NodeIslandOptions | undefined): string {
if (options === undefined) return "";
return [
options.mode ?? "",
(options.islands ?? []).join(","),
(options.native ?? []).join(","),
options.node ?? "",
options.strictUserJs === true ? "strict-user-js" : "",
].join("|");
}
@@ -0,0 +1,279 @@
import { describe, expect, test } from "vitest";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import { emptyProjectConfig, parseProjectConfig } from "../project-config.js";
import type { NodeIslandMarker } from "./detect.js";
import {
globMatch,
isMarkedUserModule,
nodeIslandPolicy,
type NodeIslandOptions,
} from "./policy.js";
import { buildNodeIslandReport, type NpmOutcome } from "./report.js";
import type { NodeIslandScan, PackageEdge, ScannedModule } from "./scan.js";
const NM = "/app/node_modules";
function mod(pkg: string | null, file: string, markers: NodeIslandMarker[] = []): ScannedModule {
return {
file,
origin: pkg === null ? "user" : "npm",
packageDir: pkg === null ? null : `${NM}/${pkg}`,
packageName: pkg,
markers,
dropped: false,
};
}
function marker(file: string, rule: NodeIslandMarker["rule"], reason: string): NodeIslandMarker {
return { rule, reason, construct: reason, loc: { file, start: 0, end: 1 } };
}
function edge(from: string | null, to: string): PackageEdge {
return {
fromDir: from === null ? null : `${NM}/${from}`,
fromFile: from === null ? "/app/main.ts" : `${NM}/${from}/index.js`,
to,
toDir: `${NM}/${to}`,
shim: false,
loc: { file: from === null ? "/app/main.ts" : `${NM}/${from}/index.js`, start: 0, end: 1 },
};
}
function gapFence(pkg: string): ScrDiagnostic {
return {
code: "SC1090",
message: "a builtin function alias as an escaping value",
loc: { file: `${NM}/${pkg}/index.js`, start: 5, end: 9 },
};
}
/** A program: main.ts imports a (constructor functions), b (a gap fence)
* and c (clean); a imports d (clean, only from a) and c. */
function program(): { scan: NodeIslandScan; fences: ScrDiagnostic[] } {
return {
scan: {
modules: [
mod(null, "/app/main.ts"),
mod("a", `${NM}/a/index.js`, [marker(`${NM}/a/index.js`, "R2.1", "constructor-function")]),
mod("b", `${NM}/b/index.js`),
mod("c", `${NM}/c/index.js`),
mod("d", `${NM}/d/index.js`),
],
edges: [edge(null, "a"), edge(null, "b"), edge(null, "c"), edge("a", "d"), edge("a", "c")],
refusals: [],
},
fences: [gapFence("b")],
};
}
function report(
options: NodeIslandOptions = {},
npm: NpmOutcome[] = [],
config = emptyProjectConfig(),
) {
const { scan, fences } = program();
return buildNodeIslandReport({
entry: "/app/main.ts",
scan,
npm,
diagnostics: [],
fences,
policy: nodeIslandPolicy(config, options),
});
}
describe("decisions", () => {
test("auto: packages with reasons are islands, gap islands first; closure and sharing", () => {
const r = report();
expect(r.islands.map((i) => [i.package, i.class, i.decision])).toEqual([
["b", "gap", "auto"],
["a", "object-model", "auto"],
]);
expect(r.islands[0]!.reason).toBe("SC1090: a builtin function alias as an escaping value");
expect(r.islands[1]!.carries).toEqual(["d"]);
expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["c", "shared", ["a"]],
["d", "inside", ["a"]],
]);
expect(r.violations).toEqual([]);
});
test("a refused package is a packaging island, classified by its refusal", () => {
const r = report({}, [
{ package: "c", status: "refused", detail: "auto: its shipped JS looks minified" },
]);
const c = r.islands.find((i) => i.package === "c")!;
expect([c.class, c.reason, c.diagnostic]).toEqual([
"packaging",
"SC2013: auto: its shipped JS looks minified",
"SC2013",
]);
});
test("forced native turns reasons into SC8003; forced node islands a clean package", () => {
const r = report({ native: ["a"], islands: ["c"] });
expect(r.islands.map((i) => [i.package, i.class, i.decision])).toEqual([
["b", "gap", "auto"],
["c", "marked", "cli"],
]);
expect(r.violations.map((v) => v.code)).toEqual(["SC8003"]);
expect(r.violations[0]!.message).toContain("'a' is marked native");
});
test("off: no islands, every package outside the native subset is SC8002", () => {
const r = report({ mode: "off" });
expect(r.islands).toEqual([]);
expect(r.violations.map((v) => [v.code, v.message.split(" (")[0]])).toEqual([
["SC8002", "npm package 'a' is outside the native subset"],
["SC8002", "npm package 'b' is outside the native subset"],
]);
});
test('explicit (or "auto": false): only marked islands', () => {
const config = parseProjectConfig(
"/app/package.json",
JSON.stringify({ scriptc: { nodeIslands: { auto: false }, packages: { b: "node" } } }),
);
const r = report({}, [], config);
expect(r.policy).toBe("explicit");
expect(r.islands.map((i) => [i.package, i.decision])).toEqual([["b", "config"]]);
expect(r.violations.map((v) => v.code)).toEqual(["SC8002"]);
});
test("command-line names the program does not import are reported", () => {
expect(report({ native: ["nope"] }).unknownPackages).toEqual(["nope"]);
});
test("unloaded packages are never islands", () => {
const config = parseProjectConfig(
"/app/package.json",
JSON.stringify({ scriptc: { packages: { a: "unloaded" } } }),
);
expect(report({}, [], config).islands.map((i) => i.package)).toEqual(["b"]);
});
});
describe("user modules", () => {
test("marked by glob in the config or by path on the command line", () => {
const config = parseProjectConfig(
"/app/package.json",
JSON.stringify({ scriptc: { nodeIslands: { modules: ["src/plugins/**/*.ts"] } } }),
);
const policy = nodeIslandPolicy(config, { islands: ["/app/src/legacy.js"] });
expect(isMarkedUserModule(policy, "/app/src/plugins/a/loader.ts")).toBe("config");
expect(isMarkedUserModule(policy, "/app/src/legacy.js")).toBe("cli");
expect(isMarkedUserModule(policy, "/app/src/main.ts")).toBe(null);
});
test("SC81xx for R2 markers in user JavaScript, only with --strict-user-js", () => {
const file = "/app/legacy.js";
const scan: NodeIslandScan = {
modules: [
mod(null, file, [
marker(file, "R2.1", "constructor-function"),
marker(file, "R2.5", "builtin-write"),
marker(file, "R1.1", "code-from-string"),
]),
],
edges: [],
refusals: [],
};
const build = (strictUserJs: boolean) =>
buildNodeIslandReport({
entry: file,
scan,
npm: [],
diagnostics: [],
fences: [],
policy: nodeIslandPolicy(emptyProjectConfig(), { strictUserJs }),
});
expect(build(false).userJs).toEqual([]);
expect(build(true).userJs.map((d) => d.code)).toEqual(["SC8101", "SC8105"]);
});
});
test("globMatch", () => {
expect(globMatch("/a/**/*.ts", "/a/b/c/d.ts")).toBe(true);
expect(globMatch("/a/**/*.ts", "/a/d.ts")).toBe(true);
expect(globMatch("/a/*.ts", "/a/b/d.ts")).toBe(false);
expect(globMatch("/a/?.js", "/a/x.js")).toBe(true);
expect(globMatch("/a/x.js", "/a/x.js")).toBe(true);
});
describe("reachability", () => {
const userEdge = (fromFile: string, to: string): PackageEdge => ({
...edge(null, to),
fromFile,
loc: { file: fromFile, start: 0, end: 1 },
});
const codeFromString = (pkg: string) =>
mod(pkg, `${NM}/${pkg}/index.js`, [
marker(`${NM}/${pkg}/index.js`, "R1.1", "code-from-string"),
]);
const build = (scan: Omit<NodeIslandScan, "refusals">, options: NodeIslandOptions = {}) =>
buildNodeIslandReport({
entry: "/app/main.ts",
scan: { ...scan, refusals: [] },
npm: [],
diagnostics: [],
fences: [],
policy: nodeIslandPolicy(emptyProjectConfig(), options),
});
test("a marked user module owns the packages only it imports", () => {
const r = build(
{
modules: [
mod(null, "/app/main.ts"),
mod(null, "/app/plugin.ts"),
mod("x", `${NM}/x/index.js`),
mod("y", `${NM}/y/index.js`),
],
edges: [userEdge("/app/plugin.ts", "x"), userEdge("/app/plugin.ts", "y"), edge(null, "y")],
},
{ islands: ["/app/plugin.ts"] },
);
expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["x", "inside", ["/app/plugin.ts"]],
["y", "shared", ["/app/plugin.ts"]],
]);
});
test("an island imported by a native package lists that import site", () => {
const r = build({
modules: [mod(null, "/app/main.ts"), mod("n", `${NM}/n/index.js`), codeFromString("i")],
edges: [edge(null, "n"), edge("n", "i")],
});
expect(r.islands.map((i) => [i.package, i.importedFrom.map((l) => l.file)])).toEqual([
["i", [`${NM}/n/index.js`]],
]);
});
test("the dependencies of a shared package are shared with the island", () => {
const r = build({
modules: [
mod(null, "/app/main.ts"),
codeFromString("a"),
mod("s", `${NM}/s/index.js`),
mod("d", `${NM}/d/index.js`),
],
edges: [edge(null, "a"), edge(null, "s"), edge("a", "s"), edge("s", "d")],
});
expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["d", "shared", ["a"]],
["s", "shared", ["a"]],
]);
});
test("with islands off, a marked user module is SC8002 and keeps the user JavaScript rules", () => {
const file = "/app/legacy.js";
const r = build(
{ modules: [mod(null, file, [marker(file, "R2.1", "constructor-function")])], edges: [] },
{ mode: "off", islands: [file], strictUserJs: true },
);
expect(r.userModules).toEqual([]);
expect(r.violations.map((v) => v.code)).toEqual(["SC8002"]);
expect(r.violations[0]!.message).toContain(`user module '${file}'`);
expect(r.userJs.map((d) => d.code)).toEqual(["SC8101"]);
});
});
@@ -0,0 +1,390 @@
/* The Node island report: which packages and user modules would run in a
* Node island, and why (DESIGN.md §1.3–§1.7). Report-only: the decisions
* are computed from the detector and the policy, and nothing about the
* build changes.
*
* 1. Reasons per package instance: pre-scan markers in its modules,
* refusals and runtime fences the lowering produced in its modules, and
* the drop-and-reload refusal of a package that did not compile.
* 2. Decision per package: the policy (forced node or native, the mode)
* applied to its reasons.
* 3. Closure: a package imported only by island packages runs inside the
* island (it is Node's business); one reached from both sides is
* reported as shared (DESIGN.md §2.6 decides it later). */
import {
nativePackageDiag,
nodeIslandsOffDiag,
nodeIslandsOffModuleDiag,
userJsObjectModelDiag,
type ScrDiagnostic,
} from "../diagnostics/diagnostic.js";
import type { SrcLoc } from "../ir/ir.js";
import {
classifyDiagnostic,
classifyMarker,
classifyRefusal,
isIslandReasonCode,
} from "./classify.js";
import {
describe,
type IslandDecision,
type NativePackage,
type NodeIslandEntry,
type NodeIslandReason,
type NodeIslandReasonSource,
type NodeIslandReport,
type UserModuleIsland,
} from "./summary.js";
import { packageDirOf, packageVersionOf } from "./origin.js";
import { isMarkedUserModule, type NodeIslandPolicy } from "./policy.js";
import { classRank, reportRank, ruleInfo } from "./rules.js";
import type { NodeIslandScan, RefusalRecord } from "./scan.js";
/** One dependency's native compilation outcome (coverage/report.ts's
* NpmStaticStatus, restated to keep this module free of coverage). */
export interface NpmOutcome {
package: string;
status: "static" | "refused";
detail?: string;
}
export interface NodeIslandReportInput {
entry: string;
scan: NodeIslandScan;
npm: readonly NpmOutcome[];
/** Lowering diagnostics of the analysis (reached and unreached), runtime
* fences separately. */
diagnostics: readonly ScrDiagnostic[];
fences: readonly ScrDiagnostic[];
policy: NodeIslandPolicy;
}
/** Reasons kept per package (the report renders the first per module). */
const REASON_CAP = 200;
interface PackageState {
key: string;
name: string;
dir: string | null;
reasons: NodeIslandReason[];
importers: Set<string>;
/** Every import site, with its importer ("" native user code,
* "user:<file>" a marked user module, else a package key). */
sites: { from: string; loc: SrcLoc }[];
island: boolean;
decision: IslandDecision;
carriedBy: string | null;
violation: boolean;
}
export function buildNodeIslandReport(input: NodeIslandReportInput): NodeIslandReport {
const policy = input.policy;
const states = new Map<string, PackageState>();
const byName = new Map<string, string[]>();
const state = (name: string, dir: string | null): PackageState => {
const key = dir ?? `name:${name}`;
let s = states.get(key);
if (s === undefined) {
s = {
key,
name,
dir,
reasons: [],
importers: new Set<string>(),
sites: [],
island: false,
decision: "auto",
carriedBy: null,
violation: false,
};
states.set(key, s);
const keys = byName.get(name) ?? [];
keys.push(key);
byName.set(name, keys);
}
return s;
};
const addReason = (s: PackageState, r: NodeIslandReason): void => {
if (s.reasons.length < REASON_CAP) s.reasons.push(r);
};
const ignored = (name: string): boolean => policy.unloaded.has(name);
// Modules and their markers.
for (const m of input.scan.modules) {
if (m.origin !== "npm" || m.packageName === null) continue;
if (ignored(m.packageName)) continue;
const s = state(m.packageName, m.packageDir);
for (const marker of m.markers) addReason(s, classifyMarker(marker));
}
// Import edges. A marked user module is an island owner of its own
// (unless islands are off, where it is a violation and stays native).
const USER = "user:";
const userIsland = (file: string): boolean =>
policy.mode !== "off" && isMarkedUserModule(policy, file) !== null;
const imports = new Map<string, Set<string>>();
for (const e of input.scan.edges) {
if (e.shim || ignored(e.to)) continue;
const s = state(e.to, e.toDir);
const from = e.fromDir ?? (userIsland(e.fromFile) ? USER + e.fromFile : "");
if (from === s.key) continue;
s.importers.add(from);
s.sites.push({ from, loc: e.loc });
let out = imports.get(from);
if (out === undefined) imports.set(from, (out = new Set<string>()));
out.add(s.key);
}
// Lowering refusals and fences inside npm modules.
const addDiagnostics = (diags: readonly ScrDiagnostic[], source: NodeIslandReasonSource) => {
for (const d of diags) {
if (!isIslandReasonCode(d.code)) continue;
const at = packageDirOf(d.loc.file);
if (at === null || ignored(at.name)) continue;
addReason(state(at.name, at.dir), classifyDiagnostic(d, source));
}
};
addDiagnostics(input.diagnostics, "refusal");
addDiagnostics(input.fences, "fence");
// Packages that did not compile.
const records = new Map<string, RefusalRecord>();
for (const r of input.scan.refusals) if (!records.has(r.package)) records.set(r.package, r);
for (const outcome of input.npm) {
if (outcome.status !== "refused" || ignored(outcome.package)) continue;
const reason = classifyRefusal(
outcome.detail ?? "its static compilation was refused",
records.get(outcome.package) ?? null,
);
if (reason === null) continue;
const keys = byName.get(outcome.package);
if (keys === undefined || keys.length === 0) {
addReason(state(outcome.package, null), reason);
continue;
}
for (const key of keys) addReason(states.get(key)!, reason);
}
// Decisions.
const violations: ScrDiagnostic[] = [];
const fallbackLoc: SrcLoc = { file: input.entry, start: 0, end: 0 };
for (const s of states.values()) {
sortReasons(s.reasons);
const forcedNode = policy.forcedNode.get(s.name);
const forcedNative = policy.forcedNative.get(s.name);
const outside = s.reasons.length > 0;
const loc = (s.sites.find((x) => x.from === "") ?? s.sites[0])?.loc ?? fallbackLoc;
if (forcedNative !== undefined) {
if (outside) {
s.violation = true;
violations.push(nativePackageDiag(s.name, describe(s.reasons[0]!), loc));
}
continue;
}
if (policy.mode === "off") {
if (outside || forcedNode !== undefined) {
s.violation = true;
violations.push(
nodeIslandsOffDiag(
s.name,
outside ? describe(s.reasons[0]!) : "it is marked to run in Node",
loc,
),
);
}
continue;
}
if (forcedNode !== undefined) {
s.island = true;
s.decision = forcedNode;
continue;
}
if (!outside) continue;
if (policy.mode === "explicit") {
s.violation = true;
violations.push(nodeIslandsOffDiag(s.name, describe(s.reasons[0]!), loc));
continue;
}
s.island = true;
s.decision = "auto";
}
// The island that owns an importer: a marked user module, an island
// package, or the island a carried package runs inside; null for native.
const ownerOf = (from: string): string | null => {
if (from.startsWith(USER)) return from;
const importer = states.get(from);
return importer === undefined ? null : importer.island ? importer.key : importer.carriedBy;
};
const ownerName = (owner: string): string =>
owner.startsWith(USER) ? owner.slice(USER.length) : states.get(owner)!.name;
// Closure: packages imported only by islands (or by packages inside
// them) run inside the island.
for (let changed = true; changed;) {
changed = false;
for (const s of states.values()) {
if (s.island || s.carriedBy !== null || s.violation) continue;
if (s.importers.size === 0 || s.importers.has("")) continue;
let carrier: string | null = null;
let all = true;
for (const from of s.importers) {
const owner = ownerOf(from);
if (owner === null) {
all = false;
break;
}
carrier ??= owner;
}
if (all && carrier !== null) {
s.carriedBy = carrier;
changed = true;
}
}
}
// Transitive reach: every non-island package an island loads, through
// carried and shared packages alike (an island package is a boundary).
const reach = new Map<string, Set<string>>();
const owners = [...imports.keys()].filter(
(k) => k.startsWith(USER) || states.get(k)?.island === true,
);
for (const owner of owners) {
const name = ownerName(owner);
const queue = [...(imports.get(owner) ?? new Set<string>())];
const seen = new Set<string>(queue);
while (queue.length > 0) {
const key = queue.pop()!;
if (states.get(key)?.island === true) continue;
let names = reach.get(key);
if (names === undefined) reach.set(key, (names = new Set<string>()));
names.add(name);
for (const next of imports.get(key) ?? new Set<string>()) {
if (!seen.has(next)) {
seen.add(next);
queue.push(next);
}
}
}
}
// Entries.
const islands: NodeIslandEntry[] = [];
const packages: NativePackage[] = [];
for (const s of states.values()) {
const version = s.dir === null ? null : packageVersionOf(s.dir);
if (s.island) {
const first = s.reasons[0];
const modules = new Set<string>();
for (const r of s.reasons) if (r.loc !== null) modules.add(r.loc.file);
const carries: string[] = [];
for (const other of states.values()) {
if (other.carriedBy === s.key) carries.push(other.name);
}
carries.sort();
islands.push({
package: s.name,
version,
path: s.dir,
class: first === undefined ? "marked" : first.class,
reason: first === undefined ? markedReason(s.decision) : describe(first),
diagnostic: first === undefined ? null : first.code,
decision: s.decision,
reasons: s.reasons,
modules: modules.size,
carries,
importedFrom: s.sites.filter((x) => ownerOf(x.from) === null).map((x) => x.loc),
evaluation: "eager",
});
continue;
}
if (s.violation) continue;
// A package that resolves to no installed JavaScript never loads (an
// optional dependency that is absent): nothing to compile or island.
if (s.dir === null && s.reasons.length === 0) continue;
const reachingIslands = [...(reach.get(s.key) ?? new Set<string>())].sort();
packages.push({
package: s.name,
version,
path: s.dir,
status: s.carriedBy !== null ? "inside" : reachingIslands.length > 0 ? "shared" : "native",
islands: s.carriedBy !== null ? [ownerName(s.carriedBy)] : reachingIslands,
});
}
islands.sort(
(a, b) =>
reportRank(a.class) - reportRank(b.class) ||
compareText(a.package, b.package) ||
compareText(a.path ?? "", b.path ?? ""),
);
packages.sort(
(a, b) => compareText(a.package, b.package) || compareText(a.path ?? "", b.path ?? ""),
);
// Marked user modules and SC81xx in user JavaScript.
const userModules: UserModuleIsland[] = [];
const userJs: ScrDiagnostic[] = [];
for (const m of input.scan.modules) {
if (m.origin !== "user") continue;
const marked = isMarkedUserModule(policy, m.file);
if (marked !== null && policy.mode !== "off") {
userModules.push({ file: m.file, source: marked });
continue;
}
if (marked !== null) {
violations.push(nodeIslandsOffModuleDiag(m.file, marked, { file: m.file, start: 0, end: 0 }));
}
if (!policy.strictUserJs) continue;
for (const marker of m.markers) {
const code = ruleInfo(marker.rule).userJsCode;
if (code === null) continue;
userJs.push(userJsObjectModelDiag(code, capitalize(marker.construct), marker.loc));
}
}
userModules.sort((a, b) => compareText(a.file, b.file));
const unknownPackages: string[] = [];
// Only command-line names: a project's configuration may list packages
// for other entries.
for (const forced of [policy.forcedNode, policy.forcedNative]) {
for (const [name, source] of forced) {
if (source === "cli" && !byName.has(name) && !unknownPackages.includes(name))
unknownPackages.push(name);
}
}
unknownPackages.sort();
return {
mode: "would-be",
policy: policy.mode,
islands,
packages,
userModules,
violations,
userJs,
unknownPackages,
};
}
function compareText(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function capitalize(text: string): string {
return text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1);
}
function markedReason(decision: IslandDecision): string {
return decision === "cli" ? "marked with --node-island" : 'marked "node" in scriptc.packages';
}
/** Decisive class first; within a class, refusals before pre-scan markers
* (the lowering is authoritative), then source order. */
function sortReasons(reasons: NodeIslandReason[]): void {
const sourceRank = (r: NodeIslandReason): number =>
r.source === "package" ? 0 : r.source === "refusal" ? 1 : r.source === "fence" ? 2 : 3;
reasons.sort(
(a, b) =>
classRank(a.class) - classRank(b.class) ||
sourceRank(a) - sourceRank(b) ||
compareText(a.loc?.file ?? "", b.loc?.file ?? "") ||
(a.loc?.start ?? 0) - (b.loc?.start ?? 0),
);
}
+109
View File
@@ -0,0 +1,109 @@
/* The refusal rules of scope B (results/design-scope-b/DESIGN.md §1.2):
* the constructs that put a module outside the native subset, grouped in
* eleven rule families, and the four classes the detector reports.
*
* runtime (R1) needs the JavaScript runtime itself
* object-model (R2) needs a mutable object model
* gap inside the native subset, but scriptc cannot compile it yet
* packaging not compilable as shipped (no declarations, minified, ...)
*
* A reason id names one construct; refusal sites carry it on their
* diagnostic (ScrDiagnostic.reason) and the pre-scan (detect.ts) reports it
* with the rule. */
export type NodeIslandClass = "runtime" | "object-model" | "gap" | "packaging" | "marked";
export type NodeIslandRule =
| "R1.1"
| "R1.2"
| "R1.3"
| "R1.4"
| "R1.5"
| "R1.6"
| "R2.1"
| "R2.2"
| "R2.3"
| "R2.4"
| "R2.5";
export interface RuleInfo {
rule: NodeIslandRule;
title: string;
/** The SC81xx code the construct is in user JavaScript (R2 only). */
userJsCode: "SC8101" | "SC8102" | "SC8103" | "SC8104" | "SC8105" | null;
}
const RULES: RuleInfo[] = [
{ rule: "R1.1", title: "code from strings", userJsCode: null },
{ rule: "R1.2", title: "dynamic module loading", userJsCode: null },
{ rule: "R1.3", title: "native addons", userJsCode: null },
{ rule: "R1.4", title: "loader and require hooks", userJsCode: null },
{ rule: "R1.5", title: "engine internals", userJsCode: null },
{ rule: "R1.6", title: "runtime-only globals", userJsCode: null },
{ rule: "R2.1", title: "constructor functions", userJsCode: "SC8101" },
{ rule: "R2.2", title: "prototype mutation", userJsCode: "SC8102" },
{ rule: "R2.3", title: "shape mutation outside the checker's view", userJsCode: "SC8103" },
{ rule: "R2.4", title: "sloppy-mode semantics", userJsCode: "SC8104" },
{ rule: "R2.5", title: "builtin and global patching", userJsCode: "SC8105" },
];
export function ruleInfo(rule: NodeIslandRule): RuleInfo {
for (const info of RULES) if (info.rule === rule) return info;
throw new Error(`unknown Node island rule ${rule}`);
}
/** Reason id → rule. Ids without a rule (gap, packaging) are absent. */
const REASON_RULES: Record<string, NodeIslandRule> = {
"code-from-string": "R1.1",
vm: "R1.1",
"dynamic-require": "R1.2",
"dynamic-import": "R1.2",
"unresolved-import": "R1.2",
"native-addon": "R1.3",
"loader-hook": "R1.4",
"engine-internal": "R1.5",
"weak-ref": "R1.6",
"constructor-function": "R2.1",
"prototype-write": "R2.2",
"define-property": "R2.3",
with: "R2.4",
"sloppy-arguments": "R2.4",
"sloppy-mode": "R2.4",
"builtin-write": "R2.5",
proxy: "R2.5",
};
export function ruleOfReason(reason: string): NodeIslandRule | null {
return REASON_RULES[reason] ?? null;
}
export function classOfRule(rule: NodeIslandRule): NodeIslandClass {
return rule.startsWith("R1.") ? "runtime" : "object-model";
}
/** The decisive class first: a runtime need outranks an object-model need,
* which outranks shipping problems; a gap is a package's class only when
* nothing else keeps it out of the native subset (fixing the gap would make
* it native). */
export function classRank(cls: NodeIslandClass): number {
if (cls === "runtime") return 0;
if (cls === "object-model") return 1;
if (cls === "packaging") return 2;
if (cls === "gap") return 3;
return 4;
}
/** Report order: gap islands first (compiler work hidden behind an
* island), then the rest by rank. */
export function reportRank(cls: NodeIslandClass): number {
if (cls === "gap") return 0;
return classRank(cls) + 1;
}
export function classDescription(cls: NodeIslandClass): string {
if (cls === "runtime") return "needs the JavaScript runtime";
if (cls === "object-model") return "needs a mutable object model";
if (cls === "gap") return "inside the native subset, but scriptc cannot compile it yet";
if (cls === "packaging") return "not compilable as shipped";
return "marked to run in Node";
}
+155
View File
@@ -0,0 +1,155 @@
/* The program-level half of the detector: which modules a program loads,
* the pre-scan markers in each, and the package import edges the island
* closure needs. The frontend calls it on its final program (and on the
* program that still held a package the drop-and-reload loop removed). */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { SrcLoc } from "../ir/ir.js";
import * as ts from "../frontend/ts7/adapter.js";
import { canonicalBuiltinModule, npmBuiltinAliasOf } from "../frontend/builtin-modules.js";
import { erasedTypeOnlyImport } from "../frontend/type-only-imports.js";
import { isShimmedImport } from "../frontend/package-shims.js";
import { resolveBareModule } from "../frontend/resolve.js";
import { isJsSourceFileName } from "../frontend/tsc-codes.js";
import { isRelativeSpecifier, packageNameOfSpecifier } from "../frontend/workspace-registry.js";
import { moduleSourceCandidates } from "../frontend/module-source-candidates.js";
import { scanModule, type NodeIslandMarker } from "./detect.js";
import { originOf, packageDirOf, type Origin } from "./origin.js";
export interface ScannedModule {
file: string;
origin: Origin;
/** The npm package directory, for npm modules. */
packageDir: string | null;
packageName: string | null;
markers: NodeIslandMarker[];
/** True for a module of a package the drop-and-reload loop removed. */
dropped: boolean;
}
/** One import of an npm package. */
export interface PackageEdge {
/** The importing package's directory; null for user code. */
fromDir: string | null;
fromFile: string;
/** The imported package's name. */
to: string;
/** Its directory when the import resolves to installed JavaScript. */
toDir: string | null;
/** Served by a registry shim, a builtin alias, or marked unloaded. */
shim: boolean;
loc: SrcLoc;
}
/** How the drop-and-reload loop refused a package. */
export type RefusalKind = "preflight" | "offender" | "degrade" | "typecheck" | "external";
export interface RefusalRecord {
package: string;
kind: RefusalKind;
/** The package-anchored preflight diagnostic, for kind "preflight". */
diag: ScrDiagnostic | null;
}
export interface NodeIslandScan {
modules: ScannedModule[];
edges: PackageEdge[];
refusals: RefusalRecord[];
}
export function emptyScan(): NodeIslandScan {
return { modules: [], edges: [], refusals: [] };
}
function locOfNode(node: ts.Node): SrcLoc {
const sf = ts.getSourceFileOfNode(node);
return { file: sf.fileName, start: ts.tokenStartOfNode(node), end: node.end };
}
/** The literal module specifiers of a file with their sites: top-level
* import and export declarations, and the require() and import() calls the
* program's shared module-source scan already collected (no second walk). */
function specifiersOf(program: ts.Program, sf: ts.SourceFile): { spec: string; node: ts.Node }[] {
const out: { spec: string; node: ts.Node }[] = [];
for (const node of sf.statements.nodes) {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier !== undefined &&
ts.isStringLiteral(node.moduleSpecifier)
) {
// Type-only imports load nothing.
const typeOnly = ts.isImportDeclaration(node) ? erasedTypeOnlyImport(node) : node.isTypeOnly;
if (!typeOnly) out.push({ spec: node.moduleSpecifier.text, node });
}
}
for (const call of moduleSourceCandidates(program, sf).calls) {
const arg = call.arguments.nodes[0];
if (
arg !== undefined &&
ts.isStringLiteralLike(arg) &&
(call.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(call.expression) && call.expression.text === "require"))
)
out.push({ spec: arg.text, node: call });
}
return out;
}
/** Scans `files`: markers for npm JavaScript (and for user JavaScript when
* `userJs`), and package edges for every file when `edges` (a program with
* no npm package has none to find). */
export function scanFiles(
program: ts.Program,
files: readonly ts.SourceFile[],
opts: { userJs: boolean; dropped: boolean; edges: boolean },
): { modules: ScannedModule[]; edges: PackageEdge[] } {
const modules: ScannedModule[] = [];
const edges: PackageEdge[] = [];
const seen = new Set<string>();
for (const sf of files) {
if (seen.has(sf.fileName)) continue;
seen.add(sf.fileName);
const origin = originOf(sf.fileName);
if (origin === "builtin" || origin === "shim") continue;
const at = origin === "npm" ? packageDirOf(sf.fileName) : null;
const js = isJsSourceFileName(sf.fileName);
const markers = js && (origin === "npm" || opts.userJs) ? scanModule(sf) : [];
modules.push({
file: sf.fileName,
origin,
packageDir: at === null ? null : at.dir,
packageName: at === null ? null : at.name,
markers,
dropped: opts.dropped,
});
if (!opts.edges) continue;
for (const { spec, node } of specifiersOf(program, sf)) {
if (isRelativeSpecifier(spec) || spec.startsWith("/") || spec.startsWith("#")) continue;
// scriptc's own packages lower at their use sites.
if (spec.startsWith("@scriptc/")) continue;
const alias = npmBuiltinAliasOf(spec);
if (alias === null && (spec.startsWith("node:") || canonicalBuiltinModule(spec) !== null))
continue;
const name = packageNameOfSpecifier(spec);
const shim = alias !== null || isShimmedImport(sf.fileName, spec);
let toDir: string | null = null;
if (!shim) {
const resolved = resolveBareModule(sf.fileName, spec, "js-only");
if (resolved !== null) {
const target = packageDirOf(resolved.typesFile);
// A workspace package compiled from source is user code.
if (target === null) continue;
toDir = target.dir;
}
}
edges.push({
fromDir: at === null ? null : at.dir,
fromFile: sf.fileName,
to: name,
toDir,
shim,
loc: locOfNode(node),
});
}
}
return { modules, edges };
}
@@ -0,0 +1,110 @@
/* The Node island report's data model and the small helpers coverage
* rendering needs. Kept free of compiler imports: the coverage report (and
* the CLI's verdict-replay fast path behind it) loads this module. */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { SrcLoc } from "../ir/ir.js";
import type { DecisionSource, NodeIslandPolicy } from "./policy.js";
import type { NodeIslandClass, NodeIslandRule } from "./rules.js";
export type NodeIslandReasonSource = "pre-scan" | "refusal" | "fence" | "package";
export interface NodeIslandReason {
class: NodeIslandClass;
rule: NodeIslandRule | null;
/** Reason id: a rules.ts id, "gap" or "packaging". */
reason: string;
/** The SC code of the refusal, when the reason is a diagnostic. */
code: string | null;
message: string;
loc: SrcLoc | null;
source: NodeIslandReasonSource;
}
export type IslandDecision = "auto" | "config" | "cli";
export interface NodeIslandEntry {
package: string;
version: string | null;
/** The package directory (the instance), when it is installed. */
path: string | null;
class: NodeIslandClass;
/** The first decisive reason, rendered. */
reason: string;
/** The SC code of the first decisive reason, when it is a refusal. */
diagnostic: string | null;
decision: IslandDecision;
/** Every reason, in report order (decisive class first, then source
* order). */
reasons: NodeIslandReason[];
/** Modules of the package with at least one reason. */
modules: number;
/** Packages imported only through this island, which run inside it. */
carries: string[];
/** Where native code imports the package: the island boundary. */
importedFrom: SrcLoc[];
/** Report-only: deferral (NI-7) is not decided yet, so every island
* would evaluate eagerly at its module position. */
evaluation: "eager";
}
export interface UserModuleIsland {
file: string;
source: DecisionSource;
}
export interface NativePackage {
package: string;
version: string | null;
path: string | null;
/** "native": compiles natively; "inside": runs inside an island's
* closure; "shared": reached from native code and from an island. */
status: "native" | "inside" | "shared";
/** For inside and shared: the islands that reach it. */
islands: string[];
}
export interface NodeIslandReport {
mode: "would-be";
policy: NodeIslandPolicy["mode"];
islands: NodeIslandEntry[];
packages: NativePackage[];
userModules: UserModuleIsland[];
/** Would-be errors: SC8002 (islands off) and SC8003 (marked native). */
violations: ScrDiagnostic[];
/** SC8101–SC8105 for user JavaScript (only with --strict-user-js). */
userJs: ScrDiagnostic[];
/** --native / --node-island / config package names the program does
* not import. */
unknownPackages: string[];
}
/** "R2.1 constructor functions: 'new Foo()' of a function" or
* "SC2012: <message>". */
export function describe(r: NodeIslandReason): string {
if (r.source === "pre-scan" && r.rule !== null) return `${r.rule} ${r.message}`;
const rule = r.rule === null ? "" : `${r.rule} `;
return r.code === null ? `${rule}${r.message}` : `${rule}${r.code}: ${r.message}`;
}
/** True when the report has decisions to show (islands, marked modules,
* policy errors or unknown names); SC81xx findings alone render as
* blockers. */
export function hasNodeIslandDecisions(report: NodeIslandReport): boolean {
return (
report.islands.length > 0 ||
report.userModules.length > 0 ||
report.violations.length > 0 ||
report.unknownPackages.length > 0
);
}
/** True when the report has anything to show. */
export function hasNodeIslandFindings(report: NodeIslandReport): boolean {
return (
report.islands.length > 0 ||
report.userModules.length > 0 ||
report.violations.length > 0 ||
report.userJs.length > 0 ||
report.unknownPackages.length > 0
);
}
@@ -0,0 +1,166 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { parseProjectConfig, readProjectConfig } from "./project-config.js";
const manifest = "/project/package.json";
function config(scriptc: unknown) {
return parseProjectConfig(manifest, JSON.stringify({ name: "app", scriptc }, null, 2));
}
test("a package.json without a scriptc key has the default configuration", () => {
const c = parseProjectConfig(manifest, '{ "name": "app" }');
expect(c.diagnostics).toEqual([]);
expect(c.packages.size).toBe(0);
expect(c.nodeIslands).toEqual({
auto: true,
modules: [],
node: null,
nodeVersion: null,
packaging: "embedded",
speculativeStart: false,
});
expect(parseProjectConfig(manifest, "{ not json").diagnostics).toEqual([]);
});
test("readProjectConfig reads the nearest package.json like parseProjectConfig", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-"));
try {
const manifests = [
'{ "name": "app", "type": "module" }',
'{ "name": "app", "\\u0073criptc": { "packages": { "encoding": "unloaded" } } }',
'{ "name": "app", "scriptc": { "packages": { "encoding": "unloaded", "x": "bogus" } } }',
];
manifests.forEach((text, i) => {
const dir = join(root, `p${i}`, "src");
mkdirSync(dir, { recursive: true });
writeFileSync(join(root, `p${i}`, "package.json"), text);
const read = readProjectConfig(join(dir, "main.ts"));
const parsed = parseProjectConfig(join(root, `p${i}`, "package.json"), text);
expect(read.manifest).toBe(parsed.manifest);
expect(read.manifestText).toBe(text);
expect([...read.packages]).toEqual([...parsed.packages]);
expect(read.nodeIslands).toEqual(parsed.nodeIslands);
expect(read.diagnostics).toEqual(parsed.diagnostics);
});
expect(
readProjectConfig(join(root, "p1", "src", "main.ts")).packages.get("encoding")?.unloaded,
).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("package settings: strings and objects", () => {
const c = config({
packages: {
encoding: "unloaded",
"dd-trace": "node",
"hot-shots": "native",
semver: "auto",
"proper-lockfile": { run: "node", evaluate: "lazy", share: "duplicate" },
},
});
expect(c.diagnostics).toEqual([]);
expect(c.packages.get("encoding")?.unloaded).toBe(true);
expect(c.packages.get("dd-trace")?.run).toBe("node");
expect(c.packages.get("hot-shots")?.run).toBe("native");
expect(c.packages.get("semver")?.run).toBe("auto");
expect(c.packages.get("proper-lockfile")).toEqual({
unloaded: false,
run: "node",
evaluate: "lazy",
share: "duplicate",
marshal: "copy",
schedule: "lockstep",
});
});
test("nodeIslands settings", () => {
const c = config({
nodeIslands: {
auto: false,
modules: ["src/plugins/*.ts"],
node: "/usr/local/bin/node",
nodeVersion: ">=24.13.1",
packaging: "beside",
speculativeStart: true,
},
wasm: ["build/a.wasm", 3],
});
expect(c.diagnostics).toEqual([]);
expect(c.nodeIslands).toEqual({
auto: false,
modules: ["src/plugins/*.ts"],
node: "/usr/local/bin/node",
nodeVersion: ">=24.13.1",
packaging: "beside",
speculativeStart: true,
});
// scriptc.wasm keeps its lenient reading.
expect(c.wasm).toEqual(["/project/build/a.wasm"]);
});
test("unknown keys and values are SC8004, located at the key", () => {
const text = JSON.stringify(
{
name: "app",
scriptc: {
packages: {
encoding: "unloded",
debug: { run: "node", evaluate: "later", color: true },
ms: 3,
},
nodeIslands: { auto: "yes", mode: "auto", modules: "src/x.ts", packaging: "zip" },
},
},
null,
2,
);
const c = parseProjectConfig(manifest, text);
expect(c.diagnostics.map((d) => [d.code, d.message])).toEqual([
["SC8004", `package.json 'scriptc.packages.encoding' has the unknown value "unloded"`],
["SC8004", `package.json 'scriptc.packages.debug.evaluate' has the unknown value "later"`],
["SC8004", "package.json 'scriptc.packages.debug.color' is not a known package setting"],
["SC8004", "package.json 'scriptc.packages.ms' must be a string or an object, not 3"],
["SC8004", `package.json 'scriptc.nodeIslands.auto' must be true or false, not "yes"`],
["SC8004", "package.json 'scriptc.nodeIslands.mode' is not a known Node island setting"],
[
"SC8004",
`package.json 'scriptc.nodeIslands.modules' must be an array of module paths or globs, not "src/x.ts"`,
],
["SC8004", `package.json 'scriptc.nodeIslands.packaging' has the unknown value "zip"`],
]);
// Locations point at the offending key inside the scriptc object.
const at = (i: number) => text.slice(c.diagnostics[i]!.loc.start).split(":")[0];
expect(at(0)).toBe('"encoding"');
expect(at(1)).toBe('"evaluate"');
expect(at(5)).toBe('"mode"');
for (const d of c.diagnostics) expect(d.loc.file).toBe(manifest);
// Invalid entries are not applied; valid siblings are.
expect(c.packages.has("encoding")).toBe(false);
expect(c.packages.has("debug")).toBe(false);
expect(c.nodeIslands.auto).toBe(true);
});
test("a non-object packages or nodeIslands value is SC8004", () => {
expect(config({ packages: ["a"] }).diagnostics.map((d) => d.message)).toEqual([
"package.json 'scriptc.packages' must be an object mapping package names to settings, not an array",
]);
expect(config({ nodeIslands: true }).diagnostics.map((d) => d.message)).toEqual([
"package.json 'scriptc.nodeIslands' must be an object, not true",
]);
// Other keys of the scriptc object belong to other readers.
expect(config({ somethingElse: 1 }).diagnostics).toEqual([]);
});
test("package setting keys named like Object.prototype members are unknown settings", () => {
const c = config({ packages: { debug: { constructor: "node", toString: "node" } } });
expect(c.diagnostics.map((d) => [d.code, d.message])).toEqual([
["SC8004", "package.json 'scriptc.packages.debug.constructor' is not a known package setting"],
["SC8004", "package.json 'scriptc.packages.debug.toString' is not a known package setting"],
]);
expect(c.packages.has("debug")).toBe(false);
});
+429
View File
@@ -0,0 +1,429 @@
/* The project configuration: the `scriptc` key of the entry's nearest
* package.json. One reader for every consumer (package shims, the
* WebAssembly scanner, the Node island policy), with a schema: a value
* this compiler does not know in `scriptc.packages` or
* `scriptc.nodeIslands` is SC8004 instead of being ignored, because an
* ignored setting is a silent divergence from what the project asked for.
*
* "scriptc": {
* "packages": {
* "encoding": "unloaded", // never loaded (package-shims.ts)
* "dd-trace": "node", // run in a Node island
* "hot-shots": "native", // must compile natively (SC8003)
* "proper-lockfile": { "run": "node", "evaluate": "lazy" }
* },
* "nodeIslands": { "auto": true, "modules": ["src/plugins/*.ts"] },
* "wasm": ["build/module.wasm"]
* }
*
* Node islands are report-only in this version: the policy decides which
* packages and modules WOULD run in Node, and coverage reports it, but code
* generation does not change. Other keys of the `scriptc` object are left
* to their own readers. */
import { dirname, join, resolve } from "node:path";
import { projectConfigDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js";
import { trackedReadFile } from "./frontend/input-tracker.js";
/** How a package runs: compiled natively, in a Node island, or decided by
* the detector. */
export type PackageRun = "native" | "node" | "auto";
export type PackageEvaluate = "auto" | "lazy" | "eager";
export type PackageShare = "auto" | "shared" | "duplicate";
export type PackageMarshal = "copy" | "handles";
export type PackageSchedule = "lockstep" | "free";
/** One `scriptc.packages.<name>` entry. `unloaded` packages never load;
* the other settings choose where the package runs. */
export interface PackageSetting {
unloaded: boolean;
run: PackageRun;
evaluate: PackageEvaluate;
share: PackageShare;
marshal: PackageMarshal;
schedule: PackageSchedule;
}
export interface NodeIslandsSetting {
/** Automatic Node islands for npm code outside the native subset. */
auto: boolean;
/** User modules (paths or globs relative to the package.json) that run
* in Node. */
modules: string[];
/** The `node` executable recorded into the binary. */
node: string | null;
/** A semver range checked at the helper handshake. */
nodeVersion: string | null;
packaging: "embedded" | "beside";
speculativeStart: boolean;
}
export interface ProjectConfig {
/** The package.json the configuration came from, or null. */
manifest: string | null;
/** Its text, for diagnostic code frames. */
manifestText: string | null;
packages: Map<string, PackageSetting>;
nodeIslands: NodeIslandsSetting;
/** `scriptc.wasm`: module paths, resolved against the manifest. */
wasm: string[];
/** SC8004 for every unknown key or value. */
diagnostics: ScrDiagnostic[];
}
const PACKAGE_STRINGS = ["unloaded", "node", "native", "auto"];
const PACKAGE_OBJECT_KEYS: Record<string, string[]> = {
run: ["native", "node", "auto"],
evaluate: ["auto", "lazy", "eager"],
share: ["auto", "shared", "duplicate"],
marshal: ["copy", "handles"],
schedule: ["lockstep", "free"],
};
const NODE_ISLANDS_KEYS = [
"auto",
"modules",
"node",
"nodeVersion",
"packaging",
"speculativeStart",
];
function defaultPackageSetting(): PackageSetting {
return {
unloaded: false,
run: "auto",
evaluate: "auto",
share: "auto",
marshal: "copy",
schedule: "lockstep",
};
}
function defaultNodeIslands(): NodeIslandsSetting {
return {
auto: true,
modules: [],
node: null,
nodeVersion: null,
packaging: "embedded",
speculativeStart: false,
};
}
/** The empty configuration of a project without a package.json. */
export function emptyProjectConfig(): ProjectConfig {
return {
manifest: null,
manifestText: null,
packages: new Map<string, PackageSetting>(),
nodeIslands: defaultNodeIslands(),
wasm: [],
diagnostics: [],
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function quoted(values: string[]): string {
return values.map((v) => `"${v}"`).join(", ");
}
/** A short rendering of a JSON value for a message. */
function shown(value: unknown): string {
if (typeof value === "string") return `"${value}"`;
if (value === null) return "null";
if (Array.isArray(value)) return "an array";
if (typeof value === "object") return "an object";
return String(value);
}
/** Reads and validates one package.json text. Exported for tests. */
export function parseProjectConfig(manifest: string, text: string): ProjectConfig {
const config = emptyProjectConfig();
config.manifest = manifest;
config.manifestText = text;
let parsed: unknown = null;
try {
parsed = JSON.parse(text);
} catch {
// Node reports a malformed package.json itself; the configuration is
// simply absent here, as before.
return config;
}
if (!isRecord(parsed)) return config;
const root = parsed["scriptc"];
if (!isRecord(root)) return config;
const locate = new JsonLocator(text);
const problem = (path: string[], detail: string, hint: string): void => {
config.diagnostics.push(
projectConfigDiag(`scriptc.${path.join(".")}`, detail, hint, {
file: manifest,
start: locate.offsetOf(["scriptc", ...path]),
end: locate.offsetOf(["scriptc", ...path]),
}),
);
};
const packages = root["packages"];
if (packages !== undefined) {
if (!isRecord(packages)) {
problem(
["packages"],
`must be an object mapping package names to settings, not ${shown(packages)}`,
`write "packages": { "<name>": "auto" }`,
);
} else {
for (const name of Object.keys(packages)) {
const value = packages[name];
const setting = defaultPackageSetting();
if (typeof value === "string") {
if (!PACKAGE_STRINGS.includes(value)) {
problem(
["packages", name],
`has the unknown value ${shown(value)}`,
`use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${Object.keys(PACKAGE_OBJECT_KEYS).join(", ")}`,
);
continue;
}
if (value === "unloaded") setting.unloaded = true;
else setting.run = value as PackageRun;
config.packages.set(name, setting);
continue;
}
if (!isRecord(value)) {
problem(
["packages", name],
`must be a string or an object, not ${shown(value)}`,
`use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${Object.keys(PACKAGE_OBJECT_KEYS).join(", ")}`,
);
continue;
}
let valid = true;
for (const key of Object.keys(value)) {
const allowed = Object.hasOwn(PACKAGE_OBJECT_KEYS, key)
? PACKAGE_OBJECT_KEYS[key]
: undefined;
if (allowed === undefined) {
problem(
["packages", name, key],
"is not a known package setting",
`the settings are ${Object.keys(PACKAGE_OBJECT_KEYS).join(", ")}`,
);
valid = false;
continue;
}
const v = value[key];
if (typeof v !== "string" || !allowed.includes(v)) {
problem(
["packages", name, key],
`has the unknown value ${shown(v)}`,
`use one of ${quoted(allowed)}`,
);
valid = false;
continue;
}
if (key === "run") setting.run = v as PackageRun;
else if (key === "evaluate") setting.evaluate = v as PackageEvaluate;
else if (key === "share") setting.share = v as PackageShare;
else if (key === "marshal") setting.marshal = v as PackageMarshal;
else setting.schedule = v as PackageSchedule;
}
if (valid) config.packages.set(name, setting);
}
}
}
const islands = root["nodeIslands"];
if (islands !== undefined) {
if (!isRecord(islands)) {
problem(
["nodeIslands"],
`must be an object, not ${shown(islands)}`,
`write "nodeIslands": { "auto": true }`,
);
} else {
const setting = config.nodeIslands;
for (const key of Object.keys(islands)) {
const v = islands[key];
if (!NODE_ISLANDS_KEYS.includes(key)) {
problem(
["nodeIslands", key],
"is not a known Node island setting",
`the settings are ${NODE_ISLANDS_KEYS.join(", ")}`,
);
} else if (key === "auto" || key === "speculativeStart") {
if (typeof v !== "boolean") {
problem(
["nodeIslands", key],
`must be true or false, not ${shown(v)}`,
"use a boolean",
);
} else if (key === "auto") setting.auto = v;
else setting.speculativeStart = v;
} else if (key === "modules") {
const list: string[] = [];
let ok = Array.isArray(v);
if (Array.isArray(v)) {
for (const entry of v as unknown[]) {
if (typeof entry === "string" && entry !== "") list.push(entry);
else ok = false;
}
}
if (!ok) {
problem(
["nodeIslands", key],
`must be an array of module paths or globs, not ${shown(v)}`,
`write "modules": ["src/plugins/loader.ts"]`,
);
} else setting.modules = list;
} else if (key === "packaging") {
if (v !== "embedded" && v !== "beside") {
problem(
["nodeIslands", key],
`has the unknown value ${shown(v)}`,
`use "embedded" or "beside"`,
);
} else setting.packaging = v;
} else if (typeof v !== "string" || v === "") {
problem(
["nodeIslands", key],
`must be a non-empty string, not ${shown(v)}`,
key === "node"
? "give the path of the node executable"
: `write a range such as ">=24.13.1"`,
);
} else if (key === "node") setting.node = v;
else setting.nodeVersion = v;
}
}
}
// `scriptc.wasm` keeps its lenient reading: string entries of an array.
const wasm = root["wasm"];
if (Array.isArray(wasm)) {
for (const entry of wasm as unknown[]) {
if (typeof entry === "string") config.wasm.push(resolve(dirname(manifest), entry));
}
}
return config;
}
let cachedManifest: string | null = null;
let cachedText: string | null = null;
let cachedConfig: ProjectConfig | null = null;
/** The configuration of the entry's nearest package.json (the first one
* found walking up from the entry's directory, readable or not). */
export function readProjectConfig(entryPath: string): ProjectConfig {
for (let dir = dirname(resolve(entryPath)); ;) {
const manifest = join(dir, "package.json");
const text = trackedReadFile(manifest);
if (text !== null) {
if (cachedConfig !== null && cachedManifest === manifest && cachedText === text)
return cachedConfig;
// A manifest without a `scriptc` key (none spelled out, none behind a
// \u escape) has the empty configuration; builds of such projects
// skip parsing and validating it.
const config =
text.includes("scriptc") || text.includes("\\u")
? parseProjectConfig(manifest, text)
: { ...emptyProjectConfig(), manifest, manifestText: text };
cachedManifest = manifest;
cachedText = text;
cachedConfig = config;
return config;
}
const parent = dirname(dir);
if (parent === dir) return emptyProjectConfig();
dir = parent;
}
}
/** Offsets of keys in a JSON text, for diagnostic locations. It follows
* object nesting so `["scriptc", "packages", "x"]` finds the key `"x"`
* inside `scriptc.packages`, not an earlier `"x"` elsewhere. */
class JsonLocator {
constructor(private readonly text: string) {}
/** The offset of the last key of `path` that could be found (0 when
* none). */
offsetOf(path: string[]): number {
let start = 0;
let end = this.text.length;
let found = 0;
for (const key of path) {
const at = this.keyIn(key, start, end);
if (at < 0) break;
found = at;
// The key's value: from after the colon to the end of its object.
const colon = this.text.indexOf(":", at);
if (colon < 0) break;
start = colon + 1;
end = this.valueEnd(start, end);
}
return found;
}
/** A direct key of the object starting at or after `start`. */
private keyIn(key: string, start: number, end: number): number {
const text = this.text;
let depth = 0;
let i = start;
while (i < end) {
const ch = text.charCodeAt(i);
if (ch === 34 /* " */) {
const close = this.stringEnd(i);
if (depth === 1 && text.slice(i + 1, close) === key) {
let j = close + 1;
while (j < end && /\s/.test(text.charAt(j))) j++;
if (text.charAt(j) === ":") return i;
}
i = close + 1;
continue;
}
if (ch === 123 /* { */ || ch === 91 /* [ */) depth++;
else if (ch === 125 /* } */ || ch === 93 /* ] */) {
depth--;
if (depth <= 0) return -1;
}
i++;
}
return -1;
}
private stringEnd(open: number): number {
const text = this.text;
let i = open + 1;
while (i < text.length) {
const ch = text.charCodeAt(i);
if (ch === 92 /* \ */) i += 2;
else if (ch === 34) return i;
else i++;
}
return text.length;
}
/** The end of the JSON value that starts at or after `start`. */
private valueEnd(start: number, end: number): number {
const text = this.text;
let i = start;
while (i < end && /\s/.test(text.charAt(i))) i++;
let depth = 0;
while (i < end) {
const ch = text.charCodeAt(i);
if (ch === 34) {
i = this.stringEnd(i) + 1;
if (depth === 0) return i;
continue;
}
if (ch === 123 || ch === 91) depth++;
else if (ch === 125 || ch === 93) {
depth--;
if (depth <= 0) return i + 1;
} else if (depth === 0 && ch === 44 /* , */) return i;
i++;
}
return end;
}
}
+6 -33
View File
@@ -24,11 +24,8 @@ import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import * as ts from "../frontend/ts7/adapter.js";
import {
trackedAccessibleEntries,
trackedFileExists,
trackedReadFile,
} from "../frontend/input-tracker.js";
import { trackedAccessibleEntries, trackedReadFile } from "../frontend/input-tracker.js";
import { readProjectConfig } from "../project-config.js";
import { analyzeWasm, isWasmHeader, type WasmModuleSummary } from "./binary.js";
import { decodeBase64 } from "./base64.js";
@@ -282,24 +279,10 @@ class Discovery {
// ── source 4: configuration ─────────────────────────────────────────
scanConfig(): void {
const manifest = nearestPackageJson(dirname(this.input.entry));
if (manifest !== null) {
const text = trackedReadFile(manifest);
if (text !== null) {
let listed: unknown;
try {
const parsed = JSON.parse(text) as { scriptc?: { wasm?: unknown } };
listed = parsed.scriptc?.wasm;
} catch {
listed = undefined;
}
if (Array.isArray(listed)) {
for (const entry of listed) {
if (typeof entry === "string") {
this.addFile(resolve(dirname(manifest), entry), { kind: "config", file: manifest });
}
}
}
const config = readProjectConfig(this.input.entry);
if (config.manifest !== null) {
for (const path of config.wasm) {
this.addFile(path, { kind: "config", file: config.manifest });
}
}
for (const path of this.input.configured) {
@@ -396,16 +379,6 @@ function collectWasmFiles(dir: string, depth: number, out: string[]): void {
}
}
function nearestPackageJson(dir: string): string | null {
for (;;) {
const candidate = join(dir, "package.json");
if (trackedFileExists(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
/** Every WebAssembly module the program's sources, packages and
* configuration make visible to the build, in discovery order. */
export function discoverWasmModules(input: WasmDiscoveryInput): WasmModuleReport[] {
@@ -0,0 +1,4 @@
// Unknown values in scriptc.packages or scriptc.nodeIslands are SC8004: a
// build warns about them and goes on (it does not use the settings), and
// coverage reports no Node islands until they are valid.
console.log("configured");
@@ -0,0 +1,14 @@
{
"private": true,
"type": "module",
"scriptc": {
"packages": {
"encoding": "unloded",
"debug": { "run": "island" }
},
"nodeIslands": {
"auto": "yes",
"mode": "auto"
}
}
}
@@ -0,0 +1,22 @@
// User JavaScript with one R2 construct per rule. With --strict-user-js,
// coverage reports each as the SC81xx error scope B makes it; without the
// flag the report is unchanged.
function Point(x) {
this.x = x;
}
Point.prototype.norm = function norm() {
return Math.abs(this.x);
};
const base = { kind: "base" };
const child = {};
Object.setPrototypeOf(child, base);
const box = {};
Object.defineProperty(box, "size", { value: 3, enumerable: false });
function legacy(a) {
arguments[0] = 2;
return a;
}
Array.prototype.last = function last() {
return this[this.length - 1];
};
console.log(new Point(-2).norm(), child.kind, box.size, legacy(1));
@@ -0,0 +1,16 @@
// Node islands, report-only: one npm package per island class, a package
// that runs inside an island's closure, one reached from both sides, and
// one that stays native. Coverage reports the islands a build would
// choose; the build itself is unchanged.
import { describe } from "ni-gap";
import { compile } from "ni-eval";
import { Counter } from "ni-ctor";
import { shout } from "ni-bundled";
import { twice } from "ni-typed";
console.log(describe());
console.log(compile("a + 1")(2));
const counter = new Counter();
counter.add(twice(2));
console.log(counter.total());
console.log(shout("hi"));
@@ -0,0 +1 @@
export declare function shout(s: string): string;
+15
View File
@@ -0,0 +1,15 @@
// A webpack bundle: its build-transform markers keep it out of the native
// subset as shipped (a packaging island).
var __webpack_modules__ = {
1: function (module) {
module.exports.shout = function shout(s) {
return s.toUpperCase() + "!";
};
},
};
function __webpack_require__(id) {
var module = { exports: {} };
__webpack_modules__[id](module);
return module.exports;
}
module.exports = __webpack_require__(1);
@@ -0,0 +1,5 @@
{
"name": "ni-bundled",
"version": "1.0.0",
"main": "index.js"
}
+4
View File
@@ -0,0 +1,4 @@
export declare class Counter {
add(n: number): void;
total(): number;
}
+15
View File
@@ -0,0 +1,15 @@
"use strict";
// A constructor function with prototype methods needs a mutable object
// model (R2.1). Its helper package runs inside its island.
const { step } = require("ni-dep");
const { twice } = require("ni-typed");
function Counter() {
this.count = 0;
}
Counter.prototype.add = function add(n) {
this.count = step(this.count, twice(n));
};
Counter.prototype.total = function total() {
return this.count;
};
exports.Counter = Counter;
@@ -0,0 +1,5 @@
{
"name": "ni-ctor",
"version": "1.0.0",
"main": "index.js"
}
+4
View File
@@ -0,0 +1,4 @@
"use strict";
exports.step = function step(a, b) {
return a + b;
};
@@ -0,0 +1,5 @@
{
"name": "ni-dep",
"version": "1.0.0",
"main": "index.js"
}
+1
View File
@@ -0,0 +1 @@
export declare function compile(body: string): (a: number) => number;
+5
View File
@@ -0,0 +1,5 @@
"use strict";
// Code from strings needs the JavaScript runtime (R1.1).
exports.compile = function compile(body) {
return new Function("a", "return " + body);
};
@@ -0,0 +1,5 @@
{
"name": "ni-eval",
"version": "1.0.0",
"main": "index.js"
}
+1
View File
@@ -0,0 +1 @@
export declare function describe(): string;
+8
View File
@@ -0,0 +1,8 @@
"use strict";
// A construct inside the native subset that scriptc does not compile yet:
// a builtin function alias used as an escaping value.
const { extname } = require("node:path");
const ext = extname;
exports.describe = function describe() {
return typeof ext;
};
@@ -0,0 +1,5 @@
{
"name": "ni-gap",
"version": "1.0.0",
"main": "index.js"
}
@@ -0,0 +1 @@
export declare function twice(n: number): number;
+4
View File
@@ -0,0 +1,4 @@
"use strict";
exports.twice = function twice(n) {
return n * 2;
};
@@ -0,0 +1,5 @@
{
"name": "ni-typed",
"version": "1.0.0",
"main": "index.js"
}
@@ -0,0 +1,5 @@
{
"name": "node-islands-coverage-fixture",
"private": true,
"type": "module"
}
@@ -0,0 +1,13 @@
Node island ni-ctor@1.0.0 (would-be, report-only)
path tests/coverage-fixtures/node-islands/node_modules/ni-ctor
class object-model: needs a mutable object model
decision automatic (npm code outside the native subset)
evaluation eager at its module position (deferred evaluation is decided by a later milestone)
reasons (first per module; decisive class first)
object-model tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js:9:1
R2.1 constructor functions: a write of 'Counter.prototype.add'
all reasons 2 (2 object-model)
boundary 1 native import site:
tests/coverage-fixtures/node-islands/main.ts:7:1
copy and handle decisions per boundary site come with compiler-managed islands
carries ni-dep
@@ -0,0 +1,36 @@
scriptc coverage tests/coverage-fixtures/node-islands-user-js.cjs
statements analyzed 13
compile statically 10 (76%)
in dynamic tier 10 (operate on values typed only at runtime; --explain-dynamic lists them)
(1 function not analyzed — signature blocked; their statements count as not static)
runs at startup 1 site (JS statements in module top-level code, which runs when the module loads)
×1 SC2004 uses of 'legacy' inherit the blocker on its declaration
at tests/coverage-fixtures/node-islands-user-js.cjs:22:57
hint: the declaration of 'legacy' did not compile — fix the diagnostic reported there and these sites clear with it
deferred to runtime 1 site (JS statements that throw their fence if executed)
×1 SC1090 parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases)
at tests/coverage-fixtures/node-islands-user-js.cjs:15:1
hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
blockers:
×1 SC8101 A write of 'Point.prototype.norm' needs a mutable JavaScript object model, which native code does not have
at tests/coverage-fixtures/node-islands-user-js.cjs:7:1
hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules
×1 SC8101 'new Point()' of a function needs a mutable JavaScript object model, which native code does not have
at tests/coverage-fixtures/node-islands-user-js.cjs:22:13
hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules
×1 SC8102 'Object.setPrototypeOf()' needs a mutable JavaScript object model, which native code does not have
at tests/coverage-fixtures/node-islands-user-js.cjs:12:1
hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules
×1 SC8103 'Object.defineProperty()' needs a mutable JavaScript object model, which native code does not have
at tests/coverage-fixtures/node-islands-user-js.cjs:14:1
hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules
×1 SC8104 A write to 'arguments[i]' in sloppy mode needs a mutable JavaScript object model, which native code does not have
at tests/coverage-fixtures/node-islands-user-js.cjs:16:3
hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules
×1 SC8105 A write of 'Array.prototype.last' needs a mutable JavaScript object model, which native code does not have
at tests/coverage-fixtures/node-islands-user-js.cjs:19:1
hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules
@@ -0,0 +1,39 @@
scriptc coverage tests/coverage-fixtures/node-islands/main.ts
statements analyzed 29
compile statically 27 (93%)
in dynamic tier 17 (operate on values typed only at runtime; --explain-dynamic lists them)
npm package compilation:
ni-bundled refused (its shipped JS carries build-transform markers (bundled/transpiled dist))
ni-gap static
ni-eval static
ni-ctor static
ni-typed static
ni-dep static
Node islands (would-be, report-only: the build is unchanged; --node-islands=auto)
gap ni-gap@1.0.0
tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js:7:17: SC1090: a builtin function alias as an escaping value (call 'ext' directly) is not supported yet
runtime ni-eval@1.0.0
tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.js:4:10: R1.1 code from strings: new Function()
object-model ni-ctor@1.0.0
tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js:9:1: R2.1 constructor functions: a write of 'Counter.prototype.add'
+1 more reason in 1 module; carries ni-dep
packaging ni-bundled@1.0.0
SC2013: its shipped JS carries build-transform markers (bundled/transpiled dist)
reached from native code and from an island: ni-typed
hint: scriptc coverage --explain-island=<package> lists every reason
deferred to runtime 1 site (JS statements that throw their fence if executed)
×1 SC1090 a builtin function alias as an escaping value (call 'ext' directly)
at tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js:7:17
hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
blockers:
×2 SC2013 cannot compile npm package 'ni-bundled' statically: its implementation is not part of the compiled module graph
at tests/coverage-fixtures/node-islands/main.ts:8:1, tests/coverage-fixtures/node-islands/main.ts:16:13
hint: use a dependency with supported source, or replace the unsupported operation; scriptc has no interpreter fallback
×1 SC2013 cannot compile npm package 'ni-bundled' statically: its shipped JS carries build-transform markers (bundled/transpiled dist)
at tests/coverage-fixtures/node-islands/main.ts:8:1
hint: to compile it anyway, name it with --npm-static=ni-bundled (or --npm-static=ni-bundled... to include its dependencies)
+115
View File
@@ -5,10 +5,12 @@ import { join } from "node:path";
import { expect, test } from "vitest";
import {
analyze,
compile,
coverageEnvelope,
coveragePasses,
renderCoverage,
renderExplainDynamic,
renderExplainIsland,
} from "@scriptc/compiler";
import { shardSelect, shardSuffix } from "./shard.js";
@@ -855,6 +857,119 @@ test("a cycle whose top level calls a builtin with a builtin function value is a
expect(out).toContain("statements analyzed");
});
test("Node islands (report-only): one package per class, gap islands first", async () => {
// ni-gap (a fence inside the native subset), ni-eval (new Function),
// ni-ctor (a constructor function, carrying ni-dep), ni-bundled (not
// compilable as shipped), ni-typed (native, also reached from ni-ctor).
const entry = fixture("node-islands/main.ts");
await expect(report(entry)).toMatchFileSnapshot("__snapshots__/coverage-node-islands.txt");
const { coverage, sourceTexts } = analyze(entry);
const islands = coverage.nodeIslands!;
expect(islands.islands.map((i) => [i.package, i.class])).toEqual([
["ni-gap", "gap"],
["ni-eval", "runtime"],
["ni-ctor", "object-model"],
["ni-bundled", "packaging"],
]);
expect(islands.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["ni-dep", "inside", ["ni-ctor"]],
["ni-typed", "shared", ["ni-ctor"]],
]);
await expect(
renderExplainIsland(coverage.nodeIslands, "ni-ctor", { sourceTexts, root: repoRoot }),
).toMatchFileSnapshot("__snapshots__/coverage-node-islands-explain.txt");
expect(renderExplainIsland(coverage.nodeIslands, "ni-dep", { root: repoRoot })).toContain(
"only ni-ctor imports it",
);
// The machine-readable form carries the same decisions.
const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts });
expect(envelope.nodeIslands?.mode).toBe("would-be");
expect(envelope.nodeIslands?.islands.map((i) => [i.package, i.class, i.at?.line])).toEqual([
["ni-gap", "gap", 7],
["ni-eval", "runtime", 4],
["ni-ctor", "object-model", 9],
["ni-bundled", "packaging", undefined],
]);
// The gates: the blocker gate is today's build (which refuses
// ni-bundled with SC2013, and nothing else); the island gates fail on
// the would-be islands.
expect(coverage.diagnostics.every((d) => d.code === "SC2013")).toBe(true);
expect(coveragePasses(coverage, "island-gaps")).toBe(false);
expect(coveragePasses(coverage, "node-islands")).toBe(false);
});
test("unknown scriptc settings warn in a build and make the island report unavailable", async () => {
const entry = fixture("node-islands-config/main.ts");
const outDir = mkdtempSync(join(tmpdir(), "scriptc-island-config-"));
try {
const built = await compile(entry, {
outputKind: "ir",
outPath: join(outDir, "main.ir.json"),
outDir,
});
expect(built.ok).toBe(true);
if (!built.ok) return;
expect((built.warnings ?? []).map((d) => [d.code, d.message])).toEqual([
["SC8004", `package.json 'scriptc.packages.encoding' has the unknown value "unloded"`],
["SC8004", `package.json 'scriptc.packages.debug.run' has the unknown value "island"`],
["SC8004", `package.json 'scriptc.nodeIslands.auto' must be true or false, not "yes"`],
["SC8004", "package.json 'scriptc.nodeIslands.mode' is not a known Node island setting"],
]);
} finally {
rmSync(outDir, { recursive: true, force: true });
}
const { coverage } = analyze(entry);
expect(coverage.nodeIslands).toBeUndefined();
expect(renderCoverage({ ...coverage, file: "main.ts" }, { root: repoRoot })).toContain(
"not analyzable: the project's scriptc configuration has 4 unknown settings",
);
});
test("Node island policy from the command line: native, forced, off", () => {
const entry = fixture("node-islands/main.ts");
const forced = analyze(entry, {
nodeIslands: { native: ["ni-eval"], islands: ["ni-typed", "not-a-dependency"] },
}).coverage.nodeIslands!;
expect(forced.islands.map((i) => [i.package, i.class, i.decision])).toEqual([
["ni-gap", "gap", "auto"],
["ni-ctor", "object-model", "auto"],
["ni-bundled", "packaging", "auto"],
["ni-typed", "marked", "cli"],
]);
expect(forced.violations.map((v) => v.code)).toEqual(["SC8003"]);
expect(forced.unknownPackages).toEqual(["not-a-dependency"]);
const off = analyze(entry, { nodeIslands: { mode: "off" } }).coverage;
expect(off.nodeIslands!.islands).toEqual([]);
expect(off.nodeIslands!.violations.map((v) => v.code)).toEqual([
"SC8002",
"SC8002",
"SC8002",
"SC8002",
]);
expect(coveragePasses(off, "island-gaps")).toBe(false);
});
test("--strict-user-js reports R2 constructs in user JavaScript as SC8101–SC8105", async () => {
const entry = fixture("node-islands-user-js.cjs");
// Without the flag the user module is not part of any island report.
expect(analyze(entry).coverage.nodeIslands).toBeUndefined();
const { coverage } = analyze(entry, { nodeIslands: { strictUserJs: true } });
expect(coverage.diagnostics.map((d) => d.code).filter((c) => c.startsWith("SC81"))).toEqual([
"SC8101",
"SC8102",
"SC8103",
"SC8104",
"SC8105",
"SC8101",
]);
await expect(
renderCoverage(
{ ...coverage, file: coverage.file.replace(repoRoot + "/", "") },
{ root: repoRoot },
),
).toMatchFileSnapshot("__snapshots__/coverage-node-islands-user-js.txt");
});
test(`every corpus program is 100% static (corpus and coverage agree${shardSuffix()})`, async () => {
// The differential corpus compiles by definition; coverage must agree.