mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Report which packages and modules would run in a Node island (#1691)
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function shout(s: string): string;
|
||||
+15
@@ -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);
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ni-bundled",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export declare class Counter {
|
||||
add(n: number): void;
|
||||
total(): number;
|
||||
}
|
||||
+15
@@ -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;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ni-ctor",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
exports.step = function step(a, b) {
|
||||
return a + b;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ni-dep",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function compile(body: string): (a: number) => number;
|
||||
+5
@@ -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);
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ni-eval",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function describe(): string;
|
||||
+8
@@ -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;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ni-gap",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function twice(n: number): number;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
exports.twice = function twice(n) {
|
||||
return n * 2;
|
||||
};
|
||||
+5
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user