@typed/virtual-modules-compiler 1.0.0-beta.1

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.
package/dist/watch.js ADDED
@@ -0,0 +1,61 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { attachCompilerHostAdapter, createTypeInfoApiSessionFactory, ensureTypeTargetBootstrapFile, } from "@typed/virtual-modules";
3
+ /**
4
+ * Run the compiler in watch mode. Mirrors tsc --watch.
5
+ */
6
+ export function runWatch(params) {
7
+ const { ts, commandLine, resolver, reportDiagnostic, reportWatchStatus, typeTargetSpecs } = params;
8
+ const { options, fileNames, projectReferences, watchOptions } = commandLine;
9
+ const sys = ts.sys;
10
+ if (!sys) {
11
+ reportDiagnostic(createDiagnostic(ts, ts.DiagnosticCategory.Error, 0, 0, "ts.sys is not available."));
12
+ process.exit(1);
13
+ }
14
+ const projectRoot = sys.getCurrentDirectory();
15
+ let effectiveFileNames = fileNames;
16
+ if (typeTargetSpecs && typeTargetSpecs.length > 0) {
17
+ const bootstrapPath = ensureTypeTargetBootstrapFile(projectRoot, typeTargetSpecs, {
18
+ mkdirSync,
19
+ writeFile: (path, content) => sys.writeFile(path, content),
20
+ });
21
+ effectiveFileNames = fileNames.includes(bootstrapPath)
22
+ ? fileNames
23
+ : [...fileNames, bootstrapPath];
24
+ }
25
+ const preliminaryHost = ts.createCompilerHost(options);
26
+ const preliminaryProgram = ts.createProgram({
27
+ rootNames: effectiveFileNames,
28
+ options,
29
+ host: preliminaryHost,
30
+ projectReferences,
31
+ });
32
+ const createTypeInfoApiSession = createTypeInfoApiSessionFactory({
33
+ ts,
34
+ program: preliminaryProgram,
35
+ ...(typeTargetSpecs?.length ? { typeTargetSpecs } : {}),
36
+ });
37
+ const createProgram = (rootNames, opts, host, oldProgram, configFileParsingDiagnostics, refs) => {
38
+ if (!host) {
39
+ host = ts.createCompilerHost(opts ?? options);
40
+ }
41
+ const adapter = attachCompilerHostAdapter({
42
+ ts,
43
+ compilerHost: host,
44
+ resolver,
45
+ projectRoot,
46
+ createTypeInfoApiSession,
47
+ reportDiagnostic,
48
+ });
49
+ try {
50
+ return ts.createEmitAndSemanticDiagnosticsBuilderProgram(rootNames ?? fileNames, opts ?? options, host, oldProgram, configFileParsingDiagnostics, refs ?? projectReferences);
51
+ }
52
+ finally {
53
+ adapter.dispose();
54
+ }
55
+ };
56
+ const host = ts.createWatchCompilerHost(effectiveFileNames, options, sys, createProgram, reportDiagnostic, reportWatchStatus, projectReferences, watchOptions);
57
+ ts.createWatchProgram(host);
58
+ }
59
+ function createDiagnostic(ts, category, code, length, messageText) {
60
+ return { category, code, file: undefined, start: 0, length, messageText };
61
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@typed/virtual-modules-compiler",
3
+ "version": "1.0.0-beta.1",
4
+ "bin": {
5
+ "vmc": "./dist/cli.js"
6
+ },
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "[ -d dist ] || rm -f tsconfig.tsbuildinfo; tsc",
19
+ "test": "vitest run --passWithNoTests"
20
+ },
21
+ "dependencies": {
22
+ "@typed/virtual-modules": "workspace:*",
23
+ "typescript": "catalog:"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^25.3.0",
27
+ "typescript": "catalog:",
28
+ "vitest": "catalog:"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src"
33
+ ]
34
+ }
package/src/build.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type * as ts from "typescript";
4
+ import type { TypeTargetSpec, VirtualModuleResolver } from "@typed/virtual-modules";
5
+ import {
6
+ attachCompilerHostAdapter,
7
+ createTypeInfoApiSessionFactory,
8
+ ensureTypeTargetBootstrapFile,
9
+ } from "@typed/virtual-modules";
10
+
11
+ function inferProjectRoot(
12
+ sys: ts.System,
13
+ rootNames: readonly string[] | undefined,
14
+ fallback: string,
15
+ ): string {
16
+ if (rootNames && rootNames.length > 0) {
17
+ return dirname(rootNames[0]);
18
+ }
19
+ return fallback;
20
+ }
21
+
22
+ export interface BuildParams {
23
+ readonly ts: typeof import("typescript");
24
+ readonly buildCommand: ts.ParsedBuildCommand;
25
+ readonly resolver: VirtualModuleResolver;
26
+ readonly reportDiagnostic: ts.DiagnosticReporter;
27
+ readonly reportSolutionBuilderStatus?: ts.DiagnosticReporter;
28
+ readonly typeTargetSpecs?: readonly TypeTargetSpec[];
29
+ }
30
+
31
+ /**
32
+ * Run the compiler in build mode (tsc -b). Mirrors tsc --build.
33
+ */
34
+ export function runBuild(params: BuildParams): number {
35
+ const { ts, buildCommand, resolver, reportDiagnostic, reportSolutionBuilderStatus, typeTargetSpecs } =
36
+ params;
37
+ const { projects, buildOptions } = buildCommand;
38
+
39
+ const sys = ts.sys;
40
+ if (!sys) {
41
+ reportDiagnostic(
42
+ createDiagnostic(ts, ts.DiagnosticCategory.Error, 0, 0, "ts.sys is not available."),
43
+ );
44
+ return 1;
45
+ }
46
+
47
+ const projectRoot = sys.getCurrentDirectory();
48
+
49
+ const createProgramForSession = (
50
+ rootNames: readonly string[],
51
+ opts: ts.CompilerOptions,
52
+ ): ts.Program => {
53
+ const h = ts.createCompilerHost(opts ?? {});
54
+ return ts.createProgram(rootNames, opts ?? {}, h);
55
+ };
56
+
57
+ const createProgram: ts.CreateProgram<ts.EmitAndSemanticDiagnosticsBuilderProgram> = (
58
+ rootNames,
59
+ opts,
60
+ host,
61
+ oldProgram,
62
+ configFileParsingDiagnostics,
63
+ refs,
64
+ ) => {
65
+ if (!host) {
66
+ host = ts.createCompilerHost(opts ?? {});
67
+ }
68
+ const root = inferProjectRoot(sys, rootNames, projectRoot);
69
+ let effectiveRootNames = rootNames ?? [];
70
+ if (typeTargetSpecs && typeTargetSpecs.length > 0) {
71
+ const bootstrapPath = ensureTypeTargetBootstrapFile(root, typeTargetSpecs, {
72
+ mkdirSync,
73
+ writeFile: (path, content) => sys.writeFile(path, content),
74
+ });
75
+ effectiveRootNames = effectiveRootNames.includes(bootstrapPath)
76
+ ? [...effectiveRootNames]
77
+ : [...effectiveRootNames, bootstrapPath];
78
+ }
79
+ const preliminaryProgram = createProgramForSession(effectiveRootNames, opts ?? {});
80
+ const createTypeInfoApiSession = createTypeInfoApiSessionFactory({
81
+ ts,
82
+ program: preliminaryProgram,
83
+ ...(typeTargetSpecs?.length ? { typeTargetSpecs } : {}),
84
+ });
85
+ const adapter = attachCompilerHostAdapter({
86
+ ts,
87
+ compilerHost: host,
88
+ resolver,
89
+ projectRoot: root,
90
+ createTypeInfoApiSession,
91
+ reportDiagnostic,
92
+ });
93
+ try {
94
+ return ts.createEmitAndSemanticDiagnosticsBuilderProgram(
95
+ effectiveRootNames,
96
+ opts ?? {},
97
+ host,
98
+ oldProgram,
99
+ configFileParsingDiagnostics,
100
+ refs,
101
+ );
102
+ } finally {
103
+ adapter.dispose();
104
+ }
105
+ };
106
+
107
+ const host = ts.createSolutionBuilderHost(
108
+ sys,
109
+ createProgram,
110
+ reportDiagnostic,
111
+ reportSolutionBuilderStatus,
112
+ );
113
+
114
+ const builder = ts.createSolutionBuilder(host, projects, buildOptions);
115
+ const exitCode = builder.build();
116
+ return exitCode === ts.ExitStatus.Success ? 0 : 1;
117
+ }
118
+
119
+ function createDiagnostic(
120
+ ts: typeof import("typescript"),
121
+ category: ts.DiagnosticCategory,
122
+ code: number,
123
+ length: number,
124
+ messageText: string,
125
+ ): ts.Diagnostic {
126
+ return { category, code, file: undefined, start: 0, length, messageText };
127
+ }
@@ -0,0 +1,207 @@
1
+ /// <reference types="node" />
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const cliPath = join(__dirname, "..", "dist", "cli.js");
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ function createTempDir(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "vmc-integration-"));
16
+ tempDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (tempDirs.length > 0) {
22
+ const dir = tempDirs.pop();
23
+ if (dir) {
24
+ try {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ } catch {
27
+ /* ignore */
28
+ }
29
+ }
30
+ }
31
+ });
32
+
33
+ function runVmc(
34
+ cwd: string,
35
+ args: string[] = [],
36
+ ): { exitCode: number; stdout: string; stderr: string } {
37
+ const result = spawnSync("node", [cliPath, ...args], {
38
+ cwd,
39
+ encoding: "utf8",
40
+ timeout: 15_000,
41
+ });
42
+ return {
43
+ exitCode: result.status ?? (result.signal ? 1 : 0),
44
+ stdout: result.stdout ?? "",
45
+ stderr: result.stderr ?? "",
46
+ };
47
+ }
48
+
49
+ describe("vmc CLI integration", () => {
50
+ it("vmc init creates vmc.config.ts in project root", () => {
51
+ const dir = createTempDir();
52
+
53
+ const { exitCode, stdout, stderr } = runVmc(dir, ["init"]);
54
+ expect(stderr).toBe("");
55
+ expect(exitCode).toBe(0);
56
+ expect(stdout).toMatch(/Created .*vmc\.config\.ts/);
57
+
58
+ const configPath = join(dir, "vmc.config.ts");
59
+ const config = readFileSync(configPath, "utf8");
60
+ expect(config).toContain("export default");
61
+ expect(config).toContain("plugins:");
62
+ expect(config).toContain("shouldResolve");
63
+ expect(config).toContain("build");
64
+ });
65
+
66
+ it("vmc init refuses to overwrite existing config without --force", () => {
67
+ const dir = createTempDir();
68
+ const configPath = join(dir, "vmc.config.ts");
69
+ writeFileSync(configPath, "export default {};\n", "utf8");
70
+
71
+ const { exitCode, stdout, stderr } = runVmc(dir, ["init"]);
72
+ expect(exitCode).toBe(1);
73
+ expect(stdout).toMatch(/already exists/);
74
+ expect(stdout).toMatch(/--force/);
75
+
76
+ const config = readFileSync(configPath, "utf8");
77
+ expect(config).toBe("export default {};\n");
78
+ });
79
+
80
+ it("vmc init --force overwrites existing config", () => {
81
+ const dir = createTempDir();
82
+ const configPath = join(dir, "vmc.config.ts");
83
+ writeFileSync(configPath, "export default {};\n", "utf8");
84
+
85
+ const { exitCode, stdout } = runVmc(dir, ["init", "--force"]);
86
+ expect(exitCode).toBe(0);
87
+ expect(stdout).toMatch(/Created/);
88
+
89
+ const config = readFileSync(configPath, "utf8");
90
+ expect(config).toContain("plugins:");
91
+ expect(config).not.toBe("export default {};\n");
92
+ });
93
+
94
+ it("compiles project with virtual modules via vmc.config.ts", () => {
95
+ const dir = createTempDir();
96
+ const srcDir = join(dir, "src");
97
+ mkdirSync(srcDir, { recursive: true });
98
+
99
+ writeFileSync(
100
+ join(dir, "tsconfig.json"),
101
+ JSON.stringify({
102
+ compilerOptions: {
103
+ strict: true,
104
+ target: "ESNext",
105
+ module: "ESNext",
106
+ moduleResolution: "Bundler",
107
+ noEmit: true,
108
+ skipLibCheck: true,
109
+ },
110
+ include: ["src"],
111
+ }),
112
+ "utf8",
113
+ );
114
+ writeFileSync(
115
+ join(dir, "vmc.config.ts"),
116
+ `export default {
117
+ plugins: [{
118
+ name: "virtual",
119
+ shouldResolve: (id) => id === "virtual:foo",
120
+ build: () => "export interface Foo { n: number }",
121
+ }],
122
+ };
123
+ `,
124
+ "utf8",
125
+ );
126
+ writeFileSync(
127
+ join(srcDir, "entry.ts"),
128
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
129
+ "utf8",
130
+ );
131
+
132
+ const { exitCode, stderr } = runVmc(dir, ["--noEmit"]);
133
+ expect(stderr).toBe("");
134
+ expect(exitCode).toBe(0);
135
+ });
136
+
137
+ it("reports diagnostics when virtual module type is wrong", () => {
138
+ const dir = createTempDir();
139
+ const srcDir = join(dir, "src");
140
+ mkdirSync(srcDir, { recursive: true });
141
+
142
+ writeFileSync(
143
+ join(dir, "tsconfig.json"),
144
+ JSON.stringify({
145
+ compilerOptions: {
146
+ strict: true,
147
+ target: "ESNext",
148
+ module: "ESNext",
149
+ moduleResolution: "Bundler",
150
+ noEmit: true,
151
+ skipLibCheck: true,
152
+ },
153
+ include: ["src"],
154
+ }),
155
+ "utf8",
156
+ );
157
+ writeFileSync(
158
+ join(dir, "vmc.config.ts"),
159
+ `export default {
160
+ plugins: [{
161
+ name: "virtual",
162
+ shouldResolve: (id) => id === "virtual:foo",
163
+ build: () => "export interface Foo { n: number }",
164
+ }],
165
+ };
166
+ `,
167
+ "utf8",
168
+ );
169
+ writeFileSync(
170
+ join(srcDir, "entry.ts"),
171
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: "wrong" };\n',
172
+ "utf8",
173
+ );
174
+
175
+ const { exitCode, stdout, stderr } = runVmc(dir, ["--noEmit"]);
176
+ expect(exitCode).toBe(1);
177
+ const output = stdout + stderr;
178
+ expect(output).toMatch(/string|number/);
179
+ });
180
+
181
+ it("compiles without vmc.config when no virtual modules", () => {
182
+ const dir = createTempDir();
183
+ const srcDir = join(dir, "src");
184
+ mkdirSync(srcDir, { recursive: true });
185
+
186
+ writeFileSync(
187
+ join(dir, "tsconfig.json"),
188
+ JSON.stringify({
189
+ compilerOptions: {
190
+ strict: true,
191
+ target: "ESNext",
192
+ module: "ESNext",
193
+ moduleResolution: "Bundler",
194
+ noEmit: true,
195
+ skipLibCheck: true,
196
+ },
197
+ include: ["src"],
198
+ }),
199
+ "utf8",
200
+ );
201
+ writeFileSync(join(srcDir, "entry.ts"), "export const x = 1;\n", "utf8");
202
+
203
+ const { exitCode, stderr } = runVmc(dir, ["--noEmit"]);
204
+ expect(stderr).toBe("");
205
+ expect(exitCode).toBe(0);
206
+ });
207
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+
3
+ import ts from "typescript";
4
+ import { resolveCommandLine } from "./commandLine.js";
5
+ import { loadResolver } from "./resolverLoader.js";
6
+ import { compile } from "./compile.js";
7
+ import { runWatch } from "./watch.js";
8
+ import { runBuild } from "./build.js";
9
+ import { runInit } from "./init.js";
10
+
11
+ const args = process.argv.slice(2);
12
+ const sys = ts.sys;
13
+
14
+ if (!sys) {
15
+ console.error("vmc: ts.sys is not available.");
16
+ process.exit(1);
17
+ }
18
+
19
+ const reportDiagnostic: ts.DiagnosticReporter = (diagnostic) => {
20
+ const message = ts.formatDiagnostic(diagnostic, {
21
+ getCanonicalFileName: (f) => f,
22
+ // oxlint-disable-next-line typescript/unbound-method
23
+ getCurrentDirectory: sys.getCurrentDirectory,
24
+ getNewLine: () => sys.newLine,
25
+ });
26
+ if (diagnostic.category === ts.DiagnosticCategory.Error) {
27
+ sys.write(message);
28
+ } else {
29
+ sys.write(message);
30
+ }
31
+ };
32
+
33
+ function main(): number {
34
+ if (args[0] === "init") {
35
+ const force = args.includes("--force");
36
+ const result = runInit({
37
+ projectRoot: sys.getCurrentDirectory(),
38
+ force,
39
+ });
40
+ if (result.ok) {
41
+ sys.write(result.message + sys.newLine);
42
+ return 0;
43
+ }
44
+ sys.write(result.message + sys.newLine);
45
+ return 1;
46
+ }
47
+
48
+ const buildIndex = args.findIndex((a) => a === "--build" || a === "-b");
49
+ const watchIndex = args.findIndex((a) => a === "--watch" || a === "-w");
50
+
51
+ if (buildIndex >= 0) {
52
+ const buildArgs = args.filter((_, i) => i !== buildIndex);
53
+ const parsed = ts.parseBuildCommand(buildArgs);
54
+ for (const d of parsed.errors) {
55
+ reportDiagnostic(d);
56
+ }
57
+ if (parsed.errors.length > 0) {
58
+ return 1;
59
+ }
60
+ const projectRoot = sys.getCurrentDirectory();
61
+ const { resolver, typeTargetSpecs } = loadResolver(projectRoot);
62
+ return runBuild({
63
+ ts,
64
+ buildCommand: parsed,
65
+ resolver,
66
+ typeTargetSpecs,
67
+ reportDiagnostic,
68
+ reportSolutionBuilderStatus: reportDiagnostic,
69
+ });
70
+ }
71
+
72
+ const watchArgs = watchIndex >= 0 ? args.filter((_, i) => i !== watchIndex) : args;
73
+ // oxlint-disable-next-line typescript/unbound-method
74
+ let commandLine = ts.parseCommandLine(watchArgs, sys.readFile);
75
+ commandLine = resolveCommandLine(ts, commandLine, sys);
76
+
77
+ for (const d of commandLine.errors) {
78
+ reportDiagnostic(d);
79
+ }
80
+ if (commandLine.errors.length > 0) {
81
+ return 1;
82
+ }
83
+
84
+ const projectRoot = sys.getCurrentDirectory();
85
+ const { resolver, typeTargetSpecs } = loadResolver(projectRoot);
86
+
87
+ if (watchIndex >= 0) {
88
+ runWatch({
89
+ ts,
90
+ commandLine,
91
+ resolver,
92
+ typeTargetSpecs,
93
+ reportDiagnostic,
94
+ reportWatchStatus: (diag, newLine, _opts, _errorCount) => {
95
+ sys.write(
96
+ ts.formatDiagnostic(diag, {
97
+ getCanonicalFileName: (f) => f,
98
+ // oxlint-disable-next-line typescript/unbound-method
99
+ getCurrentDirectory: sys.getCurrentDirectory,
100
+ getNewLine: () => newLine,
101
+ }),
102
+ );
103
+ },
104
+ });
105
+ return 0;
106
+ }
107
+
108
+ return compile({
109
+ ts,
110
+ commandLine,
111
+ resolver,
112
+ reportDiagnostic,
113
+ typeTargetSpecs,
114
+ });
115
+ }
116
+
117
+ process.exit(main());
@@ -0,0 +1,54 @@
1
+ import type * as ts from "typescript";
2
+
3
+ /**
4
+ * Resolve the parsed command line. When fileNames is empty (e.g. "tsc" with no args),
5
+ * find tsconfig.json and use getParsedCommandLineOfConfigFile to populate fileNames.
6
+ * This mirrors tsc behavior.
7
+ */
8
+ export function resolveCommandLine(
9
+ ts: typeof import("typescript"),
10
+ commandLine: ts.ParsedCommandLine,
11
+ sys: ts.System,
12
+ ): ts.ParsedCommandLine {
13
+ if (commandLine.fileNames.length > 0) {
14
+ return commandLine;
15
+ }
16
+
17
+ const project = (commandLine.options as { project?: string }).project;
18
+ const cwd = sys.getCurrentDirectory();
19
+ let configPath: string | undefined;
20
+ if (project) {
21
+ configPath = sys.fileExists(project)
22
+ ? project
23
+ : ts.findConfigFile(project, (p) => sys.fileExists(p));
24
+ } else {
25
+ configPath = ts.findConfigFile(cwd, (p) => sys.fileExists(p));
26
+ }
27
+
28
+ if (!configPath) {
29
+ return commandLine;
30
+ }
31
+
32
+ const configHost: ts.ParseConfigFileHost = {
33
+ getCurrentDirectory: () => sys.getCurrentDirectory(),
34
+ useCaseSensitiveFileNames: sys.useCaseSensitiveFileNames,
35
+ readDirectory: sys.readDirectory,
36
+ fileExists: sys.fileExists,
37
+ readFile: (p) => sys.readFile(p),
38
+ onUnRecoverableConfigFileDiagnostic: (_d) => {
39
+ // Will be reported by caller
40
+ },
41
+ };
42
+
43
+ const resolved = ts.getParsedCommandLineOfConfigFile(configPath, commandLine.options, configHost);
44
+
45
+ if (!resolved) {
46
+ return commandLine;
47
+ }
48
+
49
+ return {
50
+ ...commandLine,
51
+ ...resolved,
52
+ options: { ...commandLine.options, ...resolved.options },
53
+ };
54
+ }