From 49578aebd5b336554513a4f0f82a2dfd9f5258de Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 17 Sep 2026 18:00:46 -0500 Subject: [PATCH] fix: compile TypeScript workspace sources - Route source-only workspace package entries through the program module graph. - Preserve JavaScript package resolution and add cross-backend workspace coverage. - Document the workspace dependency boundary. Fixes #187 Co-authored-by: Yegor Shovkun <6272356+ankhzet@users.noreply.github.com> --- docs/src/app/dependencies/page.mdx | 2 + .../src/frontend/lowering/lower-modules.ts | 8 ++ packages/compiler/src/frontend/program.ts | 5 +- .../compiler/src/frontend/resolve.test.ts | 24 +++- packages/compiler/src/frontend/resolve.ts | 122 ++++++++++++++---- .../npm/workspace/wssource/describe.ts | 7 + .../fixtures/npm/workspace/wssource/index.ts | 2 + .../npm/workspace/wssource/package.json | 7 + tests/fixtures/workspace-source/main.ts | 3 + .../workspace-source/node_modules/wssource | 1 + tests/harness/workspace-source.test.ts | 33 +++++ 11 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 tests/fixtures/npm/workspace/wssource/describe.ts create mode 100644 tests/fixtures/npm/workspace/wssource/index.ts create mode 100644 tests/fixtures/npm/workspace/wssource/package.json create mode 100644 tests/fixtures/workspace-source/main.ts create mode 120000 tests/fixtures/workspace-source/node_modules/wssource create mode 100644 tests/harness/workspace-source.test.ts diff --git a/docs/src/app/dependencies/page.mdx b/docs/src/app/dependencies/page.mdx index c2c8e4fd4..8bfce4c46 100644 --- a/docs/src/app/dependencies/page.mdx +++ b/docs/src/app/dependencies/page.mdx @@ -38,6 +38,8 @@ What happened, step by step: Your own code — the `.action` callback body, the functions it calls — still compiles statically. The engine runs only what must be dynamic, and [`scriptc coverage --dynamic`](/coverage) shows the exact split, including which Node builtins the embedded packages import and whether each is shimmed. +Workspace packages whose executable entry is TypeScript compile automatically as part of your program, including package-internal `.js` import spellings that resolve to `.ts` source. Workspace packages that ship executable JavaScript keep the normal dependency policy: use `--dynamic` for the island or opt them into `--npm-static`. + ## What the island is - **It's quickjs-ng, not V8.** Embedded dependency code runs correctly but slower than under Node for CPU-bound work. The win is startup, size, memory, and deployment shape — not raw dependency throughput. diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 6e51941ce..4d5c7ae9a 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -262,6 +262,14 @@ export interface FileParts { // island runtime implementation; the preflight/import-use SC1010 // fences are the whole story. if (lowerer.externalTypes.has(spec)) continue; + // A source-only workspace package is part of the program module + // graph: resolveImport follows its node_modules link to the loaded + // TypeScript entry. Its internal `.js` spellings have already been + // resolved to `.ts` by that graph, and no source may be handed to + // the JavaScript island. + if (!isRelativeSpecifier(spec) && resolveImport(lowerer.program, fp.sf, spec) !== null) { + continue; + } const npm = resolveNpmImport(fp.sf.fileName, spec); // --npm-static: an opted-in package that made it through preflight // is a PROGRAM-MODULE dependency — its entry sits in the module diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index fd2524e36..437fd95d9 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -2157,7 +2157,8 @@ function preflight7(load: LoadResult): { diags.push(externalHostModuleDiag7(fromSpec, stmt)); continue; } - if (!isRelativeSpecifier(fromSpec)) { + const projectReexport = resolveImport7(program, sf, fromSpec); + if (!isRelativeSpecifier(fromSpec) && projectReexport === null) { // NAMED re-exports from a SUPPORTED builtin pass (`export { ok } // from "node:assert"` — a universal re-export facade facade): the // statement binds nothing locally and evaluates nothing (builtins @@ -2194,7 +2195,7 @@ function preflight7(load: LoadResult): { diags.push(unsupportedDiag("SC1014", locOf7(stmt), "re-exports from packages or builtin modules")); continue; } - const reDep = resolveImport7(program, sf, fromSpec); + const reDep = projectReexport; // `export * as ns from "./m"` re-exports the module NAMESPACE // object under a name: importers' `x.ns.member` reads resolve // statically through the same alias machinery as `import * as ns` diff --git a/packages/compiler/src/frontend/resolve.test.ts b/packages/compiler/src/frontend/resolve.test.ts index 26bf685dd..f51b451c0 100644 --- a/packages/compiler/src/frontend/resolve.test.ts +++ b/packages/compiler/src/frontend/resolve.test.ts @@ -1,8 +1,11 @@ +import { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; -import { clearResolveCaches, projectDtsRuntimeSibling, resolveProjectModule, setProjectPathMappings, setProjectRealm } from "./resolve.js"; +import { clearResolveCaches, projectDtsRuntimeSibling, resolveProjectModule, resolveWorkspaceSourceModule, setProjectPathMappings, setProjectRealm } from "./resolve.js"; + +const fixturesRoot = join(import.meta.dirname, "../../../..", "tests/fixtures"); test("resolver reset clears the active project package realm", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-resolve-reset-")); @@ -31,3 +34,22 @@ test("resolver reset clears the active project package realm", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("source-only workspace packages are project modules while JavaScript packages stay external", () => { + const sourceEntry = join(fixturesRoot, "workspace-source/main.ts"); + const sourceDir = realpathSync(join(fixturesRoot, "workspace-source/node_modules/wssource")); + const sourceModule = join(sourceDir, "index.ts"); + const sourceMember = join(sourceDir, "describe.ts"); + const jsEntry = join(fixturesRoot, "npm/cases/workspace-linked/main.ts"); + + clearResolveCaches(); + try { + expect(resolveWorkspaceSourceModule(sourceEntry, "wssource")?.typesFile).toBe(sourceModule); + expect(resolveProjectModule(sourceEntry, "wssource")).toBe(sourceModule); + expect(resolveProjectModule(sourceModule, "./describe.js")).toBe(sourceMember); + expect(resolveWorkspaceSourceModule(jsEntry, "wslinked")).toBeNull(); + expect(resolveProjectModule(jsEntry, "wslinked")).toBeNull(); + } finally { + clearResolveCaches(); + } +}); diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 47fb79094..b71af2781 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -401,6 +401,10 @@ const EXPORT_CONDITIONS = new Set(["types", "import", "default"]); * package.json the tsgo host serves for the same package. */ const JS_ONLY_CONDITIONS = new Set(["import", "default"]); +/** Node's ESM runtime conditions. Source-only workspace detection follows + * the executable branch rather than the checker-only "types" branch. */ +const RUNTIME_IMPORT_CONDITIONS = new Set(["import", "node", "default"]); + /** package.json "exports" lookup: exact subpath keys, then '*' patterns * (longest literal prefix wins), condition objects matched against the * supplied set in object-key order, arrays first-resolvable. Returns the @@ -611,14 +615,17 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin /** The one resolver entry point for source modules that compile into the * current program. Relative paths, tsconfig aliases, package imports, package - * self-references, and provenance entries all meet here; builtins and ordinary - * npm packages deliberately answer null for their dedicated callers. */ + * self-references, provenance entries, and TypeScript-entry workspace + * packages all meet here; builtins and JavaScript npm packages deliberately + * answer null for their dedicated callers. */ export function resolveProjectModule(fromFile: string, specifier: string): string | null { if (isRelativeSpecifier(specifier) || isAbsolute(specifier)) { return resolveRelativeModule(fromFile, specifier); } if (specifier.startsWith("node:")) return null; - return resolveProjectImport(fromFile, specifier); + return resolveProjectImport(fromFile, specifier) ?? + resolveWorkspaceSourceModule(fromFile, specifier)?.typesFile ?? + null; } /* 5.9.3 with allowJs resolves node_modules in TWO FULL PASSES (probed): the @@ -628,7 +635,7 @@ export function resolveProjectModule(fromFile: string, specifier: string): strin * fails does a second identical walk run admitting the JavaScript files * themselves. An untyped package with an @types twin therefore answers the * @types files; an untyped package without one answers its own .js. */ -type ResolutionPass = "types" | "js"; +type ResolutionPass = "types" | "source" | "js"; function extensionsFor(pass: ResolutionPass, flavor: "plain" | "x" | "m" | "c"): string[] { if (pass === "types") { @@ -639,6 +646,14 @@ function extensionsFor(pass: ResolutionPass, flavor: "plain" | "x" | "m" | "c"): default: return [".ts", ".tsx", ".d.ts"]; } } + if (pass === "source") { + switch (flavor) { + case "m": return [".mts"]; + case "c": return [".cts"]; + case "x": return [".tsx", ".ts"]; + default: return [".ts", ".tsx"]; + } + } switch (flavor) { case "m": return [".mjs"]; case "c": return [".cjs"]; @@ -650,10 +665,17 @@ function extensionsFor(pass: ResolutionPass, flavor: "plain" | "x" | "m" | "c"): /** File resolution of an exports-map target (or types/typings/main field) * inside node_modules, for one pass: recognized-extension substitution, * extension addition, then directory index. */ -function loadTargetInPass(pkgDir: string, target: string, pass: ResolutionPass): string | null { +function loadTargetInPass( + pkgDir: string, + target: string, + pass: ResolutionPass, + runtimeOnly = false, +): string | null { const path = join(pkgDir, target); if (pass === "types") { if (/\.(d\.ts|d\.mts|d\.cts|ts|tsx|mts|cts)$/.test(path) && isFile(path)) return path; + } else if (pass === "source") { + if (/\.(ts|tsx|mts|cts)$/.test(path) && !/\.d\.(ts|mts|cts)$/.test(path) && isFile(path)) return path; } else if (/\.(js|jsx|mjs|cjs)$/.test(path) && isFile(path)) { return path; } @@ -678,9 +700,10 @@ function loadTargetInPass(pkgDir: string, target: string, pass: ResolutionPass): if (isDirectory(path)) { const nested = pkgJsonOf(path); if (nested) { - for (const field of [nested.types, nested.typings, nested.main]) { + const fields = runtimeOnly ? [nested.main] : [nested.types, nested.typings, nested.main]; + for (const field of fields) { if (typeof field === "string" && field !== "") { - const via = loadTargetInPass(path, field, pass); + const via = loadTargetInPass(path, field, pass, runtimeOnly); if (via) return via; } } @@ -695,24 +718,31 @@ function loadTargetInPass(pkgDir: string, target: string, pass: ResolutionPass): /** node_modules file-or-directory resolution for a package-internal path (a * subpath without exports, the root lookup), for one pass. */ -function loadPathInPass(pkgDir: string, rel: string, pass: ResolutionPass): string | null { +function loadPathInPass( + pkgDir: string, + rel: string, + pass: ResolutionPass, + runtimeOnly = false, +): string | null { const base = rel === "." ? pkgDir : join(pkgDir, rel); if (rel !== ".") { - const viaFile = loadTargetInPass(pkgDir, rel, pass); + const viaFile = loadTargetInPass(pkgDir, rel, pass, runtimeOnly); if (viaFile) return viaFile; return null; } if (!isDirectory(base)) return null; const pkg = pkgJsonOf(base); if (pkg) { - for (const field of [pkg.types, pkg.typings]) { - if (typeof field === "string" && field !== "") { - const viaTypes = loadTargetInPass(base, field, pass); - if (viaTypes) return viaTypes; + if (!runtimeOnly) { + for (const field of [pkg.types, pkg.typings]) { + if (typeof field === "string" && field !== "") { + const viaTypes = loadTargetInPass(base, field, pass, runtimeOnly); + if (viaTypes) return viaTypes; + } } } if (typeof pkg.main === "string" && pkg.main !== "") { - const viaMain = loadTargetInPass(base, pkg.main, pass); + const viaMain = loadTargetInPass(base, pkg.main, pass, runtimeOnly); if (viaMain) return viaMain; } } @@ -765,11 +795,12 @@ function packageAnswer(pkgDir: string, fallbackName: string, typesFile: string): export function resolveBareModule( fromFile: string, specifier: string, - /** "js-only" forces the runtime-JS resolution regardless of the active - * --npm-static set (the auto-detection probe); "types-only" forces the - * unshadowed declaration pass for overload-overlay discovery; default - * follows the active set. */ - mode?: "js-only" | "types-only", + /** "js-only" forces --npm-static's runtime-JS resolution regardless of + * the active set (the auto-detection probe); "types-only" forces the + * unshadowed declaration pass for overload-overlay discovery; + * "runtime-js" and "runtime-source" probe Node's import-condition entry + * by executable file kind; default follows the active set. */ + mode?: "js-only" | "types-only" | "runtime-js" | "runtime-source", ): BareResolution | null { const pkgName = packageNameOfSpecifier(specifier); const rest = specifier.slice(pkgName.length).replace(/^\//, ""); @@ -777,8 +808,14 @@ export function resolveBareModule( // An opted-in --npm-static package resolves to its RUNTIME JS: the js // pass only, the "types" export condition dropped, the @types mangling // never consulted — mirroring the shadowed world the tsgo host serves. - const npmStatic = mode === "js-only" || (mode !== "types-only" && isNpmStaticPackage(pkgName)); - const conditions = npmStatic ? JS_ONLY_CONDITIONS : EXPORT_CONDITIONS; + const runtimeImport = mode === "runtime-js" || mode === "runtime-source"; + const npmStatic = mode === "js-only" || (!runtimeImport && mode !== "types-only" && isNpmStaticPackage(pkgName)); + const conditions = runtimeImport + ? RUNTIME_IMPORT_CONDITIONS + : npmStatic + ? JS_ONLY_CONDITIONS + : EXPORT_CONDITIONS; + const runtimeOnly = runtimeImport || npmStatic; const inPackage = (nmPkgDir: string, name: string, pass: ResolutionPass): BareResolution | null => { // A workspace link: the answer's realpath escaped node_modules, so the @@ -819,12 +856,12 @@ export function resolveBareModule( if (pkg?.exports !== undefined) { const target = resolveExports(pkg.exports, subpath, conditions); if (target !== null) { - const file = loadTargetInPass(nmPkgDir, target, pass); + const file = loadTargetInPass(nmPkgDir, target, pass, runtimeOnly); if (file) return withWorkspace(packageAnswer(nmPkgDir, name, file)); } return null; } - const file = loadPathInPass(nmPkgDir, subpath === "." ? "." : `./${rest}`, pass); + const file = loadPathInPass(nmPkgDir, subpath === "." ? "." : `./${rest}`, pass, runtimeOnly); if (!file) return null; // A SUBPATH answered through a nested package.json (the // @restart/hooks/useMergedRefs shape): 5.9.3 forms the packageId from @@ -862,9 +899,48 @@ export function resolveBareModule( }; if (mode === "types-only") return passOnce("types"); + if (mode === "runtime-source") return passOnce("source"); + if (mode === "runtime-js") return passOnce("js"); return npmStatic ? passOnce("js") : (passOnce("types") ?? passOnce("js")); } +/** A workspace-linked bare package whose executable entry is TypeScript, + * not shipped JavaScript. The type-resolution pass must reach that source + * directly, and the runtime-JS pass must have no answer: a workspace that + * ships dist/index.js beside src/index.ts remains an ordinary npm package + * whose JavaScript executes in the island unless --npm-static opts it in. + * + * Source-only workspace packages are part of the program author's module + * graph instead. The TypeScript program already loaded their source and + * resolved package-internal `.js` spellings to `.ts`; returning that same + * entry from resolveProjectModule lets preflight, module ordering, and + * lowering share the checker-resolved graph without feeding TypeScript to + * the JavaScript island. */ +export function resolveWorkspaceSourceModule( + fromFile: string, + specifier: string, +): BareResolution | null { + if ( + isRelativeSpecifier(specifier) || + isAbsolute(specifier) || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + const source = resolveBareModule(fromFile, specifier, "runtime-source"); + if ( + source?.workspaceDir === undefined || + !/\.(?:ts|tsx|mts|cts)$/.test(source.typesFile) || + /\.d\.(?:ts|mts|cts)$/.test(source.typesFile) + ) { + return null; + } + return resolveBareModule(fromFile, specifier, "runtime-js") === null + ? source + : null; +} + /** Resolves a `/// `-style TYPE DIRECTIVE the way * ts.resolveTypeReferenceDirective does with typeRoots emptied (the * secondary lookup only): walking up from the anchor file, per directory diff --git a/tests/fixtures/npm/workspace/wssource/describe.ts b/tests/fixtures/npm/workspace/wssource/describe.ts new file mode 100644 index 000000000..86323d364 --- /dev/null +++ b/tests/fixtures/npm/workspace/wssource/describe.ts @@ -0,0 +1,7 @@ +export interface NamedValue { + value: string; +} + +export function describe(input: NamedValue): string { + return `wssource:${input.value}:${input.value.length}`; +} diff --git a/tests/fixtures/npm/workspace/wssource/index.ts b/tests/fixtures/npm/workspace/wssource/index.ts new file mode 100644 index 000000000..a5a79a5ab --- /dev/null +++ b/tests/fixtures/npm/workspace/wssource/index.ts @@ -0,0 +1,2 @@ +export { describe } from "./describe.js"; +export type { NamedValue } from "./describe.js"; diff --git a/tests/fixtures/npm/workspace/wssource/package.json b/tests/fixtures/npm/workspace/wssource/package.json new file mode 100644 index 000000000..1504a571b --- /dev/null +++ b/tests/fixtures/npm/workspace/wssource/package.json @@ -0,0 +1,7 @@ +{ + "name": "wssource", + "version": "1.0.0", + "type": "module", + "main": "./index.ts", + "types": "./index.ts" +} diff --git a/tests/fixtures/workspace-source/main.ts b/tests/fixtures/workspace-source/main.ts new file mode 100644 index 000000000..a3aa2a181 --- /dev/null +++ b/tests/fixtures/workspace-source/main.ts @@ -0,0 +1,3 @@ +import { describe } from "wssource"; + +console.log(describe({ value: "linked" })); diff --git a/tests/fixtures/workspace-source/node_modules/wssource b/tests/fixtures/workspace-source/node_modules/wssource new file mode 120000 index 000000000..31457bdf4 --- /dev/null +++ b/tests/fixtures/workspace-source/node_modules/wssource @@ -0,0 +1 @@ +../../npm/workspace/wssource \ No newline at end of file diff --git a/tests/harness/workspace-source.test.ts b/tests/harness/workspace-source.test.ts new file mode 100644 index 000000000..8ab49786d --- /dev/null +++ b/tests/harness/workspace-source.test.ts @@ -0,0 +1,33 @@ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const entry = join(repoRoot, "tests/fixtures/workspace-source/main.ts"); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests/workspace-source"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +test.for(["c", "llvm"] as const)( + "a TypeScript-source workspace package compiles as program modules with the %s backend", + async (backend) => { + const outDir = join(cacheDir, `${backend}-${sanitize ? "san" : "plain"}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { + outPath: join(outDir, "program"), + outDir, + backend, + sanitize, + }); + if (!result.ok) { + throw new Error(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")); + } + + const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" }); + expect(stdout).toBe("wssource:linked:6\n"); + }, + 120_000, +);