xbintsc 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +21 -3
  2. package/README.zh-CN.md +18 -3
  3. package/dist/src/cli/main.js +8 -0
  4. package/dist/src/cli/main.js.map +1 -1
  5. package/dist/src/codegen/generator/context.d.ts +27 -0
  6. package/dist/src/codegen/generator/context.js +103 -10
  7. package/dist/src/codegen/generator/context.js.map +1 -1
  8. package/dist/src/codegen/generator/expressions/primary.js +4 -0
  9. package/dist/src/codegen/generator/expressions/primary.js.map +1 -1
  10. package/dist/src/codegen/generator/state.d.ts +7 -0
  11. package/dist/src/codegen/generator/state.js.map +1 -1
  12. package/dist/src/driver/compiler.d.ts +7 -0
  13. package/dist/src/driver/compiler.js +29 -2
  14. package/dist/src/driver/compiler.js.map +1 -1
  15. package/dist/src/extensions/catalog.d.ts +14 -0
  16. package/dist/src/extensions/catalog.js +17 -0
  17. package/dist/src/extensions/catalog.js.map +1 -0
  18. package/dist/src/extensions/registry.d.ts +15 -0
  19. package/dist/src/extensions/registry.js +27 -0
  20. package/dist/src/extensions/registry.js.map +1 -1
  21. package/dist/src/index.d.ts +1 -0
  22. package/dist/src/index.js +1 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/tests/cli/main.test.js +24 -0
  25. package/dist/tests/cli/main.test.js.map +1 -1
  26. package/dist/tests/driver/compiler.test.js +81 -1
  27. package/dist/tests/driver/compiler.test.js.map +1 -1
  28. package/dist/tests/extensions/registry.test.js +11 -0
  29. package/dist/tests/extensions/registry.test.js.map +1 -1
  30. package/package.json +3 -2
  31. package/scripts/coverage-runtime.ts +338 -0
  32. package/src/cli/main.ts +7 -0
  33. package/src/codegen/generator/context.ts +113 -8
  34. package/src/codegen/generator/expressions/primary.ts +3 -0
  35. package/src/codegen/generator/state.ts +7 -0
  36. package/src/driver/compiler.ts +29 -2
  37. package/src/extensions/catalog.ts +19 -0
  38. package/src/extensions/registry.ts +28 -0
  39. package/src/index.ts +1 -0
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime (C) coverage for xbintsc.
4
+ *
5
+ * Vitest's V8 provider only sees the compiler's TypeScript sources
6
+ * (the `src/` tree, see vitest.config.ts). The C runtime under `runtime/` is
7
+ * invisible to it, so this script drives the *other* coverage system: clang's
8
+ * source-based instrumentation.
9
+ *
10
+ * The lever is `CCC_OVERRIDE_OPTIONS`. Exporting
11
+ * `+-fprofile-instr-generate +-fcoverage-mapping` makes clang append those
12
+ * flags to every compile and link it performs. The driver forwards the ambient
13
+ * environment to clang, so no xbintsc option or source change is needed to
14
+ * instrument the runtime. Instrumented programs then write `.profraw` files
15
+ * named by `LLVM_PROFILE_FILE`, which `llvm-profdata` and `llvm-cov` turn
16
+ * into a per-source report.
17
+ *
18
+ * Two things make this work against the test suite:
19
+ *
20
+ * - The e2e harness gives every suite its own temporary cache, so the
21
+ * instrumented runtime objects are always compiled fresh (no stale,
22
+ * un-instrumented object can be reused).
23
+ * - `llvm-cov report` needs a binary that *contains* the coverage mapping,
24
+ * but the harness deletes each compiled program when its suite ends. A
25
+ * single reference binary, built from the same runtime sources with the
26
+ * same clang and flags, carries the mapping for every runtime file, so the
27
+ * merged profile can be reported against it.
28
+ *
29
+ * Usage:
30
+ * npm run coverage:runtime instrument, run the tests, report
31
+ * npm run coverage:runtime -- --report-only report profiles from an earlier run
32
+ * npm run coverage:runtime -- tests/e2e forward args to `vitest run`
33
+ *
34
+ * Options:
35
+ * --report-only Skip the test run; merge whatever profiles already exist.
36
+ * --prof-dir <dir> Where compiled programs write .profraw (default build/runtime-prof).
37
+ * --out-dir <dir> Where the report is written (default coverage/runtime).
38
+ * --threshold <pct> Fail when total runtime line coverage is below <pct>.
39
+ * --html Also write an HTML report under <out-dir>/html.
40
+ * --keep-raw Keep the .profraw files after merging.
41
+ * --strict Fail (instead of skipping) when the LLVM tools are missing.
42
+ * -h, --help Show this help.
43
+ */
44
+
45
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
46
+ import { spawnSync, type SpawnSyncOptions } from "node:child_process";
47
+ import { dirname, join, relative, resolve } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+ import { build } from "../src/driver/compiler.js";
50
+ import { createDefaultRegistry } from "../src/extensions/registry.js";
51
+ import { nodeExtension } from "../src/extensions/node/index.js";
52
+ import { findRuntimeDir } from "../src/driver/paths.js";
53
+ import { resolveToolchain } from "../src/driver/toolchain-provider.js";
54
+
55
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
56
+
57
+ /** Flags clang appends to every compile/link in an instrumented build. */
58
+ const INSTRUMENT = "+-fprofile-instr-generate +-fcoverage-mapping";
59
+
60
+ interface Options {
61
+ reportOnly: boolean;
62
+ profDir: string;
63
+ outDir: string;
64
+ threshold?: number;
65
+ html: boolean;
66
+ keepRaw: boolean;
67
+ strict: boolean;
68
+ testArgs: string[];
69
+ }
70
+
71
+ interface CommandResult {
72
+ status: number;
73
+ stdout: string;
74
+ stderr: string;
75
+ }
76
+
77
+ function fail(message: string): never {
78
+ console.error(`coverage-runtime: ${message}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ function skip(message: string, strict: boolean): never {
83
+ if (strict) fail(message);
84
+ console.warn(`coverage-runtime: skipping - ${message}`);
85
+ process.exit(0);
86
+ }
87
+
88
+ function usage(): void {
89
+ process.stdout.write(
90
+ [
91
+ "Usage: npm run coverage:runtime [-- options] [-- vitest args]",
92
+ "",
93
+ " --report-only merge profiles from an earlier instrumented run",
94
+ " --prof-dir <dir> .profraw output directory (default build/runtime-prof)",
95
+ " --out-dir <dir> report directory (default coverage/runtime)",
96
+ " --threshold <pct> fail below this total line coverage",
97
+ " --html also write an HTML report",
98
+ " --keep-raw keep .profraw files after merging",
99
+ " --strict fail when the LLVM tools are missing",
100
+ "",
101
+ ].join("\n"),
102
+ );
103
+ }
104
+
105
+ function parseArgs(argv: readonly string[]): Options {
106
+ const options: Options = {
107
+ reportOnly: false,
108
+ profDir: join(root, "build", "runtime-prof"),
109
+ outDir: join(root, "coverage", "runtime"),
110
+ html: false,
111
+ keepRaw: false,
112
+ strict: false,
113
+ testArgs: [],
114
+ };
115
+ for (let index = 0; index < argv.length; index += 1) {
116
+ const arg = argv[index]!;
117
+ if (arg === "--report-only") options.reportOnly = true;
118
+ else if (arg === "--html") options.html = true;
119
+ else if (arg === "--keep-raw") options.keepRaw = true;
120
+ else if (arg === "--strict") options.strict = true;
121
+ else if (arg === "--prof-dir") options.profDir = resolve(requireValue(argv, (index += 1), arg));
122
+ else if (arg === "--out-dir") options.outDir = resolve(requireValue(argv, (index += 1), arg));
123
+ else if (arg === "--threshold") {
124
+ const value = requireValue(argv, (index += 1), arg);
125
+ const parsed = Number(value);
126
+ if (!Number.isFinite(parsed)) fail(`--threshold expects a number, got '${value}'`);
127
+ options.threshold = parsed;
128
+ } else if (arg === "-h" || arg === "--help") {
129
+ usage();
130
+ process.exit(0);
131
+ } else if (arg.startsWith("--")) {
132
+ fail(`unknown option '${arg}' (see --help)`);
133
+ } else {
134
+ options.testArgs.push(arg);
135
+ }
136
+ }
137
+ return options;
138
+ }
139
+
140
+ function requireValue(argv: readonly string[], index: number, flag: string): string {
141
+ const value = argv[index];
142
+ if (value === undefined) fail(`${flag} needs a value`);
143
+ return value;
144
+ }
145
+
146
+ function run(command: string, args: readonly string[], options: SpawnSyncOptions = {}): CommandResult {
147
+ const result = spawnSync(command, [...args], { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, ...options });
148
+ return {
149
+ status: result.status ?? (result.error ? 1 : 0),
150
+ stdout: result.stdout ?? "",
151
+ stderr: result.stderr ?? (result.error ? String(result.error.message) : ""),
152
+ };
153
+ }
154
+
155
+ /** True when `command` resolves and answers `--version` successfully. */
156
+ function runs(command: string): boolean {
157
+ const result = spawnSync(command, ["--version"], { encoding: "utf8" });
158
+ return !result.error && result.status === 0;
159
+ }
160
+
161
+ /**
162
+ * Locate the LLVM tool `name` (`llvm-profdata`/`llvm-cov`) that matches the
163
+ * clang the driver resolved. The bundled toolchains ship the tools next to
164
+ * clang and cover the versioned system installs, so search there first: mixing
165
+ * versions makes `llvm-profdata` reject the profile format.
166
+ */
167
+ function findTool(clang: string, name: string): string | undefined {
168
+ const exe = process.platform === "win32" ? ".exe" : "";
169
+ const candidates: string[] = [];
170
+ const directory = dirname(clang);
171
+ if (directory !== "." && directory !== "") candidates.push(join(directory, name + exe));
172
+ const version = run(clang, ["--version"]);
173
+ const major = /version (\d+)/.exec(`${version.stdout}${version.stderr}`)?.[1];
174
+ if (major) candidates.push(`${name}-${major}`, `${name}-${major}${exe}`);
175
+ candidates.push(name + exe, name);
176
+ for (const version of ["18", "19", "20", "21", "22", "23"]) {
177
+ candidates.push(`${name}-${version}`, `${name}-${version}${exe}`);
178
+ }
179
+ for (const candidate of candidates) {
180
+ if (runs(candidate)) return candidate;
181
+ }
182
+ if (process.platform === "darwin") {
183
+ // The Command Line Tools expose the tools through `xcrun` rather than PATH.
184
+ const found = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
185
+ const path = (found.stdout ?? "").trim();
186
+ if (!found.error && found.status === 0 && path.length > 0 && runs(path)) return path;
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function collectProfiles(directory: string): string[] {
192
+ if (!existsSync(directory)) return [];
193
+ return readdirSync(directory)
194
+ .filter((name) => name.endsWith(".profraw"))
195
+ .sort()
196
+ .map((name) => join(directory, name));
197
+ }
198
+
199
+ /** Merge profiles, chunking the input so a huge list cannot overflow argv. */
200
+ function mergeProfiles(tool: string, inputs: readonly string[], output: string, scratch: string): void {
201
+ const chunkSize = 128;
202
+ if (inputs.length <= chunkSize) {
203
+ const result = run(tool, ["merge", "-sparse", ...inputs, "-o", output]);
204
+ if (result.status !== 0) fail(`llvm-profdata merge failed:\n${result.stderr}`);
205
+ return;
206
+ }
207
+ const parts: string[] = [];
208
+ for (let index = 0; index < inputs.length; index += chunkSize) {
209
+ const part = join(scratch, `part-${parts.length}.profdata`);
210
+ const result = run(tool, ["merge", "-sparse", ...inputs.slice(index, index + chunkSize), "-o", part]);
211
+ if (result.status !== 0) fail(`llvm-profdata merge failed:\n${result.stderr}`);
212
+ parts.push(part);
213
+ }
214
+ const result = run(tool, ["merge", "-sparse", ...parts, "-o", output]);
215
+ if (result.status !== 0) fail(`llvm-profdata merge failed:\n${result.stderr}`);
216
+ for (const part of parts) rmSync(part, { force: true });
217
+ }
218
+
219
+ /**
220
+ * Build the binary whose embedded coverage mapping the merged profile is
221
+ * reported against. It must be instrumented with the same clang and flags as
222
+ * the test-produced binaries, and it carries the Node extension so that
223
+ * extension's C sources are covered too.
224
+ */
225
+ function buildReference(directory: string): string {
226
+ mkdirSync(directory, { recursive: true });
227
+ const entry = join(directory, "reference.ts");
228
+ writeFileSync(entry, "console.log(1);\n");
229
+ const registry = createDefaultRegistry().register(nodeExtension);
230
+ const result = build(entry, {
231
+ emit: "exe",
232
+ outDir: directory,
233
+ cacheDir: join(directory, "cache"),
234
+ extensions: registry,
235
+ preferPrebuilt: false,
236
+ force: true,
237
+ });
238
+ const errors = result.diagnostics.filter((diagnostic) => diagnostic.category === "error");
239
+ if (errors.length > 0) fail(`reference build failed:\n${errors.map((e) => e.message).join("\n")}`);
240
+ return result.outputPath;
241
+ }
242
+
243
+ function runTests(options: Options): void {
244
+ const vitest = join(root, "node_modules", "vitest", "vitest.mjs");
245
+ if (!existsSync(vitest)) fail("vitest is not installed; run 'npm install' first");
246
+ rmSync(options.profDir, { recursive: true, force: true });
247
+ mkdirSync(options.profDir, { recursive: true });
248
+ console.log("coverage-runtime: running the test suite with instrumented clang");
249
+ const result = spawnSync(process.execPath, [vitest, "run", ...options.testArgs], {
250
+ stdio: "inherit",
251
+ cwd: root,
252
+ env: {
253
+ ...process.env,
254
+ LLVM_PROFILE_FILE: join(options.profDir, "%p-%m.profraw"),
255
+ xbintsc_CACHE_DIR: join(root, "build", "runtime-cov-cache"),
256
+ // A prebuilt runtime/lib archive is not instrumented, so force the driver
257
+ // to compile (and thus instrument) the C sources instead.
258
+ xbintsc_PREFER_PREBUILT: "0",
259
+ },
260
+ });
261
+ if (result.status !== 0) fail(`tests failed (exit ${result.status ?? "unknown"})`);
262
+ }
263
+
264
+ function main(): void {
265
+ const options = parseArgs(process.argv.slice(2));
266
+
267
+ // Set once, before anything shells out to clang, so both the test run and the
268
+ // reference build are instrumented even in --report-only mode.
269
+ const existing = process.env.CCC_OVERRIDE_OPTIONS;
270
+ process.env.CCC_OVERRIDE_OPTIONS = existing ? `${existing} ${INSTRUMENT}` : INSTRUMENT;
271
+
272
+ let clang: string;
273
+ try {
274
+ clang = resolveToolchain().clang;
275
+ } catch (error) {
276
+ skip(`no clang-compatible compiler available (${String(error)})`, options.strict);
277
+ }
278
+ const profdataTool = findTool(clang, "llvm-profdata");
279
+ const covTool = findTool(clang, "llvm-cov");
280
+ if (!profdataTool || !covTool) {
281
+ const missing = [
282
+ profdataTool ? undefined : "llvm-profdata",
283
+ covTool ? undefined : "llvm-cov",
284
+ ].filter(Boolean).join(", ");
285
+ skip(`${missing} not found for ${clang}; install LLVM or use the bundled toolchain`, options.strict);
286
+ }
287
+
288
+ if (!options.reportOnly) runTests(options);
289
+
290
+ const profiles = collectProfiles(options.profDir);
291
+ if (profiles.length === 0) {
292
+ skip(`no .profraw files in ${relative(root, options.profDir)} (did the tests run?)`, options.strict);
293
+ }
294
+ console.log(`coverage-runtime: merging ${profiles.length} profile(s)`);
295
+
296
+ mkdirSync(options.outDir, { recursive: true });
297
+ const referenceDir = join(root, "build", "runtime-coverage");
298
+ const reference = buildReference(referenceDir);
299
+ const profdata = join(options.outDir, "runtime.profdata");
300
+ mergeProfiles(profdataTool!, profiles, profdata, options.outDir);
301
+
302
+ const runtimeDir = findRuntimeDir();
303
+ const report = run(covTool!, ["report", reference, `--instr-profile=${profdata}`, runtimeDir]);
304
+ if (report.status !== 0) fail(`llvm-cov report failed:\n${report.stderr}`);
305
+ writeFileSync(join(options.outDir, "report.txt"), report.stdout);
306
+ process.stdout.write(report.stdout);
307
+
308
+ const summary = run(covTool!, ["export", reference, `--instr-profile=${profdata}`, "--summary-only"]);
309
+ if (summary.status !== 0) fail(`llvm-cov export failed:\n${summary.stderr}`);
310
+ const parsed = JSON.parse(summary.stdout) as {
311
+ data?: { totals?: { lines?: { percent?: number } } }[];
312
+ };
313
+ const percent = parsed.data?.[0]?.totals?.lines?.percent ?? 0;
314
+ console.log(`coverage-runtime: runtime line coverage ${percent.toFixed(2)}%`);
315
+ console.log(`coverage-runtime: report -> ${relative(root, join(options.outDir, "report.txt"))}`);
316
+
317
+ if (options.html) {
318
+ const htmlDir = join(options.outDir, "html");
319
+ rmSync(htmlDir, { recursive: true, force: true });
320
+ const shown = run(covTool!, [
321
+ "show",
322
+ reference,
323
+ `--instr-profile=${profdata}`,
324
+ "--format=html",
325
+ `--output-dir=${htmlDir}`,
326
+ runtimeDir,
327
+ ]);
328
+ if (shown.status !== 0) fail(`llvm-cov show failed:\n${shown.stderr}`);
329
+ console.log(`coverage-runtime: HTML report -> ${relative(root, htmlDir)}`);
330
+ }
331
+
332
+ if (!options.keepRaw) rmSync(options.profDir, { recursive: true, force: true });
333
+ if (options.threshold !== undefined && percent < options.threshold) {
334
+ fail(`runtime line coverage ${percent.toFixed(2)}% is below the ${options.threshold}% threshold`);
335
+ }
336
+ }
337
+
338
+ main();
package/src/cli/main.ts CHANGED
@@ -23,6 +23,7 @@ import { runtimeLibDir } from "../driver/runtime-lib.js";
23
23
  import { resolveToolchain } from "../driver/toolchain-provider.js";
24
24
  import { realRunner } from "../driver/toolchain.js";
25
25
  import { createDefaultRegistry, type ExtensionRegistry } from "../extensions/registry.js";
26
+ import { bundledExtensions } from "../extensions/catalog.js";
26
27
  import { nodeExtension } from "../extensions/node/index.js";
27
28
  import { nativeExtensionFromManifest } from "../extensions/native.js";
28
29
 
@@ -112,6 +113,12 @@ function buildRegistry(flags: Map<string, string | boolean>): ExtensionRegistry
112
113
  registry.register(nativeExtensionFromManifest(manifest));
113
114
  }
114
115
  }
116
+ // Hint the extensions that ship with xbintsc but were not enabled, so a
117
+ // missing `import` of a known module points at the flag that enables it
118
+ // (`pass --ext node`) instead of failing later with a confusing error.
119
+ for (const extension of bundledExtensions()) {
120
+ if (!registry.has(extension.name)) registry.hintExtension(extension);
121
+ }
115
122
  return registry;
116
123
  }
117
124
 
@@ -26,6 +26,14 @@ export class GeneratorContext {
26
26
  readonly diagnostics: DiagnosticBag;
27
27
  readonly builtins: Readonly<Record<string, BuiltinFunction>>;
28
28
  readonly modules: Readonly<Record<string, ExtensionModule>>;
29
+ /** Module specifier -> extension name for known but unregistered extensions. */
30
+ readonly moduleHints: Readonly<Record<string, string>>;
31
+ /**
32
+ * Imported symbols whose providing module was already reported as missing
33
+ * (hint emitted). Referencing them is skipped so the actionable hint is the
34
+ * only diagnostic, instead of a second "cannot be used as a value" error.
35
+ */
36
+ readonly missingModuleSymbols = new Set<number>();
29
37
  /** Host the emitted IR targets (the process by default; see `CodegenOptions`). */
30
38
  readonly target: { readonly platform: string; readonly arch: string };
31
39
  /** Imported symbol id -> the module binding it refers to. */
@@ -59,6 +67,7 @@ export class GeneratorContext {
59
67
  this.binding = bind(sourceFile);
60
68
  this.builtins = options.builtins ?? {};
61
69
  this.modules = options.modules ?? {};
70
+ this.moduleHints = options.moduleHints ?? {};
62
71
  this.target = options.target ?? { platform: process.platform, arch: process.arch };
63
72
  this.resolveImports();
64
73
  }
@@ -69,14 +78,19 @@ export class GeneratorContext {
69
78
  * the namespace dispatch tables.
70
79
  */
71
80
  private resolveImports(): void {
72
- if (Object.keys(this.modules).length === 0) return;
73
81
  for (const statement of this.sourceFile.statements) {
74
82
  if (statement.kind !== SyntaxKind.ImportDeclaration) continue;
75
83
  const declaration = statement as ImportDeclaration;
76
- const module = this.modules[declaration.moduleSpecifier.value];
77
- if (!module) continue;
78
84
  const clause = declaration.importClause;
79
- if (!clause) continue;
85
+ // Type-only imports are erased at runtime, so a missing host module is
86
+ // not an error for them.
87
+ if (!clause || clause.isTypeOnly) continue;
88
+ const specifier = declaration.moduleSpecifier.value;
89
+ const module = this.modules[specifier];
90
+ if (!module) {
91
+ this.reportMissingModule(declaration, specifier);
92
+ continue;
93
+ }
80
94
  if (clause.name) {
81
95
  const symbol = this.binding.symbolOfDeclaration.get(clause.name);
82
96
  if (symbol) {
@@ -87,11 +101,13 @@ export class GeneratorContext {
87
101
  }
88
102
  const bindings = clause.namedBindings;
89
103
  if (bindings && bindings.kind === SyntaxKind.NamedImports) {
90
- for (const specifier of bindings.elements) {
91
- const importedName = specifier.propertyName?.text ?? specifier.name.text;
104
+ for (const specifierNode of bindings.elements) {
105
+ if (specifierNode.isTypeOnly) continue;
106
+ const importedName = specifierNode.propertyName?.text ?? specifierNode.name.text;
92
107
  const exported = module.exports?.[importedName];
93
- const symbol = this.binding.symbolOfDeclaration.get(specifier.name);
94
- if (symbol && exported) {
108
+ const symbol = this.binding.symbolOfDeclaration.get(specifierNode.name);
109
+ if (!symbol) continue;
110
+ if (exported) {
95
111
  this.importExports.set(symbol.id, exported);
96
112
  // A named constructor (`import { Buffer } from "buffer"`) also
97
113
  // inherits its module's static dispatcher, so `Buffer.from(...)`
@@ -99,6 +115,14 @@ export class GeneratorContext {
99
115
  if (exported.isConstructor && module.namespace) {
100
116
  this.importNamespaces.set(symbol.id, module.namespace);
101
117
  }
118
+ } else if (this.usedAsValue(symbol)) {
119
+ this.diagnostics.error(
120
+ DiagnosticCode.ModuleNotFound,
121
+ `Module '"${specifier}"' has no exported member '${importedName}'`,
122
+ specifierNode.name,
123
+ this.sourceFile.fileName,
124
+ );
125
+ this.missingModuleSymbols.add(symbol.id);
102
126
  }
103
127
  }
104
128
  } else if (bindings && bindings.kind === SyntaxKind.NamespaceImport) {
@@ -108,6 +132,52 @@ export class GeneratorContext {
108
132
  }
109
133
  }
110
134
 
135
+ /**
136
+ * Diagnose an import of a module the registry cannot provide.
137
+ *
138
+ * A *hinted* module belongs to an extension the caller knows about but did
139
+ * not enable, so point at the flag that enables it. Anything else is a bare
140
+ * specifier xbintsc cannot link - almost always a third-party package from
141
+ * `node_modules` - and must be reported at the import site rather than
142
+ * degrading into a downstream "cannot be used as a value" error.
143
+ */
144
+ private reportMissingModule(declaration: ImportDeclaration, specifier: string): void {
145
+ const provider = this.moduleHints[specifier];
146
+ if (provider) {
147
+ this.diagnostics.error(
148
+ DiagnosticCode.ModuleNotFound,
149
+ `module '${specifier}' is provided by the '${provider}' extension; pass --ext ${provider}`,
150
+ declaration.moduleSpecifier,
151
+ this.sourceFile.fileName,
152
+ );
153
+ this.markMissingModuleSymbols(declaration);
154
+ return;
155
+ }
156
+ // A path that is not a bare specifier is a bundling concern; the driver
157
+ // already reports unresolved relative imports, so stay out of the way.
158
+ if (!isBareSpecifier(specifier)) return;
159
+ // Only report when a binding is actually read: a type-only use is erased
160
+ // by the binder and must keep compiling.
161
+ const symbols = this.importBindingSymbols(declaration);
162
+ if (!symbols.some((symbol) => this.usedAsValue(symbol))) return;
163
+ this.diagnostics.error(
164
+ DiagnosticCode.ModuleNotFound,
165
+ `module '${specifier}' is not supported: xbintsc can only import built-in platform modules and ` +
166
+ "relative '.ts' files; third-party npm packages (node_modules) are not implemented yet",
167
+ declaration.moduleSpecifier,
168
+ this.sourceFile.fileName,
169
+ );
170
+ for (const symbol of symbols) this.missingModuleSymbols.add(symbol.id);
171
+ }
172
+
173
+ /**
174
+ * True when a binding is read as a runtime value. Type positions are not
175
+ * bound (the binder skips them), so a symbol with no references is erased.
176
+ */
177
+ private usedAsValue(symbol: SymbolInfo): boolean {
178
+ return symbol.references.length > 0;
179
+ }
180
+
111
181
  /**
112
182
  * Bind a default/namespace import to either the module's runtime namespace
113
183
  * dispatcher (when it has one) or its named exports (when it does not).
@@ -120,6 +190,31 @@ export class GeneratorContext {
120
190
  }
121
191
  }
122
192
 
193
+ /** Every binding an import declaration introduces (including type-only ones). */
194
+ private importBindingSymbols(declaration: ImportDeclaration): SymbolInfo[] {
195
+ const clause = declaration.importClause;
196
+ if (!clause) return [];
197
+ const names: Identifier[] = [];
198
+ if (clause.name) names.push(clause.name);
199
+ const bindings = clause.namedBindings;
200
+ if (bindings && bindings.kind === SyntaxKind.NamedImports) {
201
+ for (const specifier of bindings.elements) names.push(specifier.name);
202
+ } else if (bindings && bindings.kind === SyntaxKind.NamespaceImport) {
203
+ names.push(bindings.name);
204
+ }
205
+ const symbols: SymbolInfo[] = [];
206
+ for (const name of names) {
207
+ const symbol = this.binding.symbolOfDeclaration.get(name);
208
+ if (symbol) symbols.push(symbol);
209
+ }
210
+ return symbols;
211
+ }
212
+
213
+ /** Record every symbol introduced by an import of a missing known module. */
214
+ private markMissingModuleSymbols(declaration: ImportDeclaration): void {
215
+ for (const symbol of this.importBindingSymbols(declaration)) this.missingModuleSymbols.add(symbol.id);
216
+ }
217
+
123
218
  /** Namespace name an imported alias refers to, if any. */
124
219
  namespaceOfSymbol(symbol: SymbolInfo | undefined): string | undefined {
125
220
  if (!symbol || symbol.kind !== SymbolKind.Import) return undefined;
@@ -320,3 +415,13 @@ export class GeneratorContext {
320
415
  );
321
416
  }
322
417
  }
418
+
419
+ /** A bare module specifier (`fs`, `node:fs`, `@scope/pkg`), not a file path. */
420
+ function isBareSpecifier(specifier: string): boolean {
421
+ return (
422
+ !specifier.startsWith(".") &&
423
+ !specifier.startsWith("/") &&
424
+ !specifier.startsWith("\\") &&
425
+ !/^[A-Za-z]:[\\/]/.test(specifier)
426
+ );
427
+ }
@@ -166,6 +166,9 @@ export const primaryExpressionMethods: PrimaryExpressionMethods = {
166
166
  return this.emitFunctionValue(symbol);
167
167
  }
168
168
  if (symbol.kind === SymbolKind.Import) {
169
+ // The providing module was reported as missing (with a `pass --ext`
170
+ // hint) while resolving imports; don't pile on a second error.
171
+ if (this.missingModuleSymbols.has(symbol.id)) return i64(XT_UNDEFINED);
169
172
  // Some module bindings are plain values rather than functions
170
173
  // (`isMainThread`, `workerData`, ...). They resolve through a nullary
171
174
  // runtime getter instead of being called or namespaced.
@@ -62,6 +62,13 @@ export interface CodegenOptions {
62
62
  readonly builtins?: Readonly<Record<string, BuiltinFunction>>;
63
63
  /** Importable modules supplied by registered extensions. */
64
64
  readonly modules?: Readonly<Record<string, ExtensionModule>>;
65
+ /**
66
+ * Module specifier -> extension name for modules that a *known but
67
+ * unregistered* extension provides. Used to report an actionable
68
+ * `pass --ext node` diagnostic when such a module is imported without
69
+ * enabling the extension.
70
+ */
71
+ readonly moduleHints?: Readonly<Record<string, string>>;
65
72
  /**
66
73
  * Host the IR is generated for. xbintsc builds for its own host, so this
67
74
  * defaults to the running process; it is overridable for tests.
@@ -72,6 +72,7 @@ export function compileString(source: string, fileName = "input.ts", extensions?
72
72
  const { ir } = generate(sourceFile, diagnostics, {
73
73
  builtins: registry.builtins(),
74
74
  modules: registry.modules(),
75
+ moduleHints: registry.moduleHints(),
75
76
  });
76
77
  return { ir, diagnostics: diagnostics.diagnostics };
77
78
  }
@@ -101,10 +102,35 @@ export function compileEntry(entryPath: string, extensions?: ExtensionRegistry):
101
102
  const { ir } = generate(sourceFile, diagnostics, {
102
103
  builtins: registry.builtins(),
103
104
  modules: registry.modules(),
105
+ moduleHints: registry.moduleHints(),
104
106
  });
105
107
  return { ir, diagnostics: diagnostics.diagnostics };
106
108
  }
107
109
 
110
+ /**
111
+ * Where the incremental cache lives when the caller does not name a directory:
112
+ * `xbintsc_CACHE_DIR` when set, otherwise `<cwd>/.xbintsc`. The override keeps
113
+ * an instrumented build (see `scripts/coverage-runtime.ts`) from reusing the
114
+ * un-instrumented objects in a normal cache, which would silently yield no
115
+ * profile data.
116
+ */
117
+ function defaultCacheDir(): string {
118
+ return process.env.xbintsc_CACHE_DIR || join(process.cwd(), ".xbintsc");
119
+ }
120
+
121
+ /**
122
+ * Whether a build may link a prebuilt `runtime/lib` archive. The explicit option
123
+ * wins; otherwise `xbintsc_PREFER_PREBUILT` decides (`0`/`false` disables) and
124
+ * the default is to use an archive when one exists. Instrumented builds set it
125
+ * to `0` so the C runtime is compiled (and therefore instrumented) from source.
126
+ */
127
+ export function resolvePreferPrebuilt(explicit: boolean | undefined): boolean {
128
+ if (explicit !== undefined) return explicit;
129
+ const raw = process.env.xbintsc_PREFER_PREBUILT;
130
+ if (raw === undefined || raw === "") return true;
131
+ return raw !== "0" && raw.toLowerCase() !== "false";
132
+ }
133
+
108
134
  function executableName(entry: string, outDir: string, explicit: string | undefined): string {
109
135
  if (explicit) return resolve(explicit);
110
136
  const base = basename(entry, extname(entry));
@@ -116,7 +142,7 @@ export function build(entryPath: string, options: BuildOptions = {}): BuildResul
116
142
  const runner = options.runner ?? realRunner;
117
143
  const registry = options.extensions ?? createDefaultRegistry();
118
144
  const outDir = resolve(options.outDir ?? join(process.cwd(), "build"));
119
- const cacheDir = resolve(options.cacheDir ?? join(process.cwd(), ".xbintsc"));
145
+ const cacheDir = resolve(options.cacheDir ?? defaultCacheDir());
120
146
  const emit: EmitKind = options.emit ?? "exe";
121
147
  const optimize = options.optimize ?? "2";
122
148
 
@@ -173,6 +199,7 @@ export function build(entryPath: string, options: BuildOptions = {}): BuildResul
173
199
  const { ir } = generate(sourceFile, diagnostics, {
174
200
  builtins: registry.builtins(),
175
201
  modules: registry.modules(),
202
+ moduleHints: registry.moduleHints(),
176
203
  });
177
204
 
178
205
  if (options.verbose) {
@@ -215,7 +242,7 @@ export function build(entryPath: string, options: BuildOptions = {}): BuildResul
215
242
  runtimeDir,
216
243
  cacheDir,
217
244
  registry,
218
- options.preferPrebuilt ?? true,
245
+ resolvePreferPrebuilt(options.preferPrebuilt),
219
246
  toolchain.env,
220
247
  );
221
248
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Catalog of the extensions shipped with xbintsc.
3
+ *
4
+ * Callers (the CLI, embedding tools) use this to hint the compiler about
5
+ * modules that *would* be available if an extension were enabled, so an import
6
+ * of a known module without its `--ext` flag fails with an actionable message
7
+ * (`pass --ext node`) rather than a confusing downstream error.
8
+ *
9
+ * The core compiler stays platform agnostic: it only sees the resulting hint
10
+ * table, never the Node extension itself.
11
+ */
12
+
13
+ import type { Extension } from "./registry.js";
14
+ import { nodeExtension } from "./node/index.js";
15
+
16
+ /** Every extension bundled with xbintsc, in registration order. */
17
+ export function bundledExtensions(): readonly Extension[] {
18
+ return [nodeExtension];
19
+ }