c64bridge 0.8.1 → 0.9.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.
Files changed (92) hide show
  1. package/.github/prompts/steer.prompt.md +55 -0
  2. package/AGENTS.md +9 -7
  3. package/README.md +192 -154
  4. package/data/embeddings_asm.json +1 -1
  5. package/data/embeddings_basic.json +1 -1
  6. package/data/embeddings_hardware.json +1 -1
  7. package/data/embeddings_mixed.json +1 -1
  8. package/data/embeddings_other.json +1 -1
  9. package/data/graphics/petscii-style-guide.md +4 -4
  10. package/data/graphics/sprite-charset-best-practices.md +9 -9
  11. package/data/vice/vice-binary-monitor-spec.md +938 -0
  12. package/dist/.tsbuildinfo +1 -1
  13. package/dist/c64Client.js +76 -2
  14. package/dist/device.js +129 -13
  15. package/dist/mcp-server.js +1 -1
  16. package/dist/prompts/registry.js +27 -25
  17. package/dist/rag/indexer.js +18 -8
  18. package/dist/rag/knowledgeIndex.js +112 -79
  19. package/dist/rag/resourceUris.js +93 -0
  20. package/dist/sidwaveCompiler.js +1 -1
  21. package/dist/{assemblyConverter.js → tools/assember.js} +10 -1
  22. package/dist/tools/audio.js +11 -11
  23. package/dist/{basicConverter.js → tools/basicTokenizer.js} +1 -1
  24. package/dist/tools/debug.js +159 -6
  25. package/dist/tools/developer.js +13 -13
  26. package/dist/tools/disassembler.js +478 -0
  27. package/dist/tools/graphics.js +10 -10
  28. package/dist/tools/input.js +338 -0
  29. package/dist/tools/machineControl.js +5 -5
  30. package/dist/tools/memory.js +336 -12
  31. package/dist/tools/meta/index.js +1 -1
  32. package/dist/tools/printer.js +11 -12
  33. package/dist/tools/programRunners.js +61 -22
  34. package/dist/tools/rag.js +9 -9
  35. package/dist/tools/registry/batch.js +167 -0
  36. package/dist/tools/registry/config.js +1 -1
  37. package/dist/tools/registry/disk.js +1 -1
  38. package/dist/tools/registry/drive.js +1 -1
  39. package/dist/tools/registry/extract.js +1 -1
  40. package/dist/tools/registry/graphics.js +102 -2
  41. package/dist/tools/registry/index.js +4 -0
  42. package/dist/tools/registry/input.js +2 -0
  43. package/dist/tools/registry/memory.js +62 -2
  44. package/dist/tools/registry/platform.js +1 -1
  45. package/dist/tools/registry/printer.js +1 -1
  46. package/dist/tools/registry/program.js +1 -1
  47. package/dist/tools/registry/rag.js +1 -1
  48. package/dist/tools/registry/sound.js +1 -1
  49. package/dist/tools/registry/stream.js +1 -1
  50. package/dist/tools/registry/system.js +1 -1
  51. package/dist/tools/schema.js +4 -5
  52. package/dist/tools/storage.js +15 -15
  53. package/dist/tools/streaming.js +3 -3
  54. package/dist/tools/symbolRegistry.js +49 -0
  55. package/dist/tools/types.js +56 -4
  56. package/dist/tools/vice.js +2 -5
  57. package/dist/vice/process.js +120 -2
  58. package/dist/vice/readiness.js +35 -0
  59. package/dist/vice/smokeOptions.js +44 -0
  60. package/dist/vice/viceClient.js +5 -1
  61. package/dist/viceRunner.js +2 -0
  62. package/doc/plans/prompts/README.md +7 -7
  63. package/mcp/prompts.json +42 -59
  64. package/mcp/protocol-examples.json +2 -2
  65. package/mcp/resources.json +179 -166
  66. package/mcp/schemas/c64_batch.schema.json +53 -0
  67. package/mcp/schemas/c64_config.schema.json +525 -283
  68. package/mcp/schemas/c64_debug.schema.json +763 -304
  69. package/mcp/schemas/c64_disk.schema.json +499 -243
  70. package/mcp/schemas/c64_drive.schema.json +165 -90
  71. package/mcp/schemas/c64_extract.schema.json +533 -274
  72. package/mcp/schemas/c64_graphics.schema.json +607 -295
  73. package/mcp/schemas/c64_input.schema.json +136 -0
  74. package/mcp/schemas/c64_memory.schema.json +637 -128
  75. package/mcp/schemas/c64_printer.schema.json +393 -196
  76. package/mcp/schemas/c64_program.schema.json +699 -340
  77. package/mcp/schemas/c64_rag.schema.json +100 -54
  78. package/mcp/schemas/c64_select_backend.schema.json +49 -23
  79. package/mcp/schemas/c64_sound.schema.json +1092 -577
  80. package/mcp/schemas/c64_stream.schema.json +89 -47
  81. package/mcp/schemas/c64_system.schema.json +351 -185
  82. package/mcp/schemas/c64_vice.schema.json +85 -47
  83. package/mcp/server.json +1 -1
  84. package/mcp/tools.json +7299 -3346
  85. package/mcp.json +3 -3
  86. package/package.json +17 -14
  87. package/scripts/fetch-external-sources.mjs +1 -2
  88. package/scripts/run-coverage.mjs +33 -1
  89. package/scripts/run-tests.mjs +2 -2
  90. package/scripts/run-tests.ts +275 -52
  91. package/scripts/update-readme.ts +132 -3
  92. package/server.json +2 -2
package/mcp.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Stdio-based Model Context Protocol (MCP) server that bridges Commodore 64 Ultimate hardware with AI agents.",
5
5
  "entry": "dist/index.js",
6
6
  "type": "stdio",
@@ -113,11 +113,11 @@
113
113
  "default": "120000000"
114
114
  },
115
115
  "VICE_BINARY": {
116
- "description": "VICE binary to launch for managed emulator sessions and audio capture",
116
+ "description": "VICE binary to launch for managed emulator sessions and audio capture; automatic search is used only when this override is missing or invalid",
117
117
  "default": "x64sc"
118
118
  },
119
119
  "VICE_DIRECTORY": {
120
- "description": "Override the VICE resource directory used for ROM and UI asset discovery",
120
+ "description": "Override the VICE resource directory used for ROM and UI asset discovery; automatic search is used only when this override is missing or invalid",
121
121
  "default": "auto-detect"
122
122
  },
123
123
  "VICE_HOST": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "mcpName": "io.github.chrisgleissner/c64bridge",
5
5
  "description": "C64 Bridge MCP server for Commodore 64 Ultimate hardware and VICE emulator control",
6
6
  "type": "module",
@@ -22,9 +22,11 @@
22
22
  "lint": "npm run check",
23
23
  "test": "node scripts/invoke-bun.mjs scripts/run-tests.ts",
24
24
  "test:node": "node scripts/run-tests.mjs",
25
+ "test:vice:mock": "npm run test -- --platform=vice --target=mock",
26
+ "test:vice:device": "FORCE_XVFB=1 VICE_VISIBLE=false VICE_WARP=true npm run test -- --platform=vice --target=device",
25
27
  "coverage": "node scripts/run-coverage.mjs",
26
28
  "coverage:single": "node scripts/invoke-bun.mjs scripts/run-tests.ts --coverage",
27
- "test:matrix": "npm run test -- --platform=c64u --target=mock && npm run test -- --platform=vice --target=mock && npm run test -- --platform=vice --target=device",
29
+ "test:matrix": "npm run test -- --platform=c64u --target=mock && npm run test:vice:mock && npm run test:vice:device",
28
30
  "mcp:generate": "node scripts/invoke-bun.mjs scripts/generate-mcp-interface.ts",
29
31
  "check:package": "node scripts/check-package.mjs",
30
32
  "check:run-local": "bash scripts/run-mcp-check.sh local 5",
@@ -40,7 +42,8 @@
40
42
  "changelog:generate": "node scripts/generate-changelog.mjs",
41
43
  "release:prepare": "node scripts/prepare-release.mjs",
42
44
  "compare:rest": "node scripts/compare-rest-apis.mjs",
43
- "vice:smoke": "node scripts/invoke-bun.mjs test/vice/viceSmokeTest.ts"
45
+ "vice:smoke": "node scripts/invoke-bun.mjs test/vice/viceSmokeTest.ts",
46
+ "vice:smoke:visible": "node scripts/invoke-bun.mjs test/vice/viceSmokeTest.ts --visible-demo"
44
47
  },
45
48
  "keywords": [
46
49
  "mcp",
@@ -59,24 +62,24 @@
59
62
  "node": ">=24"
60
63
  },
61
64
  "dependencies": {
62
- "@modelcontextprotocol/sdk": "^1.20.2",
63
- "axios": "^1.7.7",
65
+ "@modelcontextprotocol/sdk": "^1.29.0",
66
+ "axios": "^1.16.0",
64
67
  "date-fns": "^4.1.0",
65
- "fastify": "^5.6.1",
66
- "jimp": "^1.6.0",
67
- "meyda": "^5.6.2",
68
- "octokit": "^5.0.4",
69
- "pitchfinder": "^2.2.0",
68
+ "fastify": "^5.8.5",
69
+ "jimp": "^1.6.1",
70
+ "meyda": "^5.6.3",
71
+ "octokit": "^5.0.5",
72
+ "pitchfinder": "^2.3.4",
70
73
  "reflect-metadata": "^0.2.2",
71
- "yaml": "^2.6.0"
74
+ "yaml": "^2.8.4"
72
75
  },
73
76
  "optionalDependencies": {
74
77
  "naudiodon": "^2.3.6"
75
78
  },
76
79
  "devDependencies": {
77
- "@types/node": "^24.12.0",
78
- "ts-node": "^10.9.2",
79
- "typescript": "^5.9.3"
80
+ "@types/node": "^25.6.0",
81
+ "tsx": "^4.21.0",
82
+ "typescript": "^6.0.3"
80
83
  },
81
84
  "files": [
82
85
  "mcp.json",
@@ -7,8 +7,7 @@ import { register } from 'node:module';
7
7
  async function main() {
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(__dirname, '..');
10
- const tsNodeLoader = pathToFileURL(path.join(projectRoot, 'node_modules', 'ts-node', 'esm.mjs')).href;
11
- register(tsNodeLoader, pathToFileURL(projectRoot));
10
+ register("tsx/esm", pathToFileURL(projectRoot));
12
11
  const { fetchFromCsv } = await import('../src/rag/externalFetcher.ts');
13
12
  // Resolve sources.csv from env, dist/rag, or src/rag (in that order)
14
13
  let csvPath = process.env.RAG_SOURCES_CSV;
@@ -13,6 +13,24 @@ const runner = path.join(repoRoot, "scripts", "invoke-bun.mjs");
13
13
  const configPath = path.join(repoRoot, ".c8rc.json");
14
14
  const testRoot = path.join(repoRoot, "test");
15
15
  const DEFAULT_COVERAGE_SHARD_SIZE = 12;
16
+ const DEFAULT_VICE_MOCK_TEST_FILES = [
17
+ "test/device.test.mjs",
18
+ "test/viceIntegration.test.mjs",
19
+ "test/viceModule.test.mjs",
20
+ "test/groupedToolsShims.test.mjs",
21
+ "test/inputModule.test.mjs",
22
+ "test/batchAndDisplayState.test.mjs",
23
+ "test/toolsTypes.test.mjs",
24
+ "test/platformRegistry.test.mjs",
25
+ "test/meta/program.test.mjs",
26
+ "test/mcpServerIntegration.test.mjs",
27
+ "test/c64Client.test.mjs",
28
+ "test/vice/viceSmokeTest.ts",
29
+ ];
30
+ const DEFAULT_VICE_DEVICE_TEST_FILES = [
31
+ "test/device.test.mjs",
32
+ "test/vice/viceSmokeTest.ts",
33
+ ];
16
34
 
17
35
  const legs = [
18
36
  { name: "c64u-mock", args: ["--platform=c64u", "--target=mock"] },
@@ -24,6 +42,11 @@ const supplementalTests = [
24
42
  "test/toolsCoverage.test.mjs",
25
43
  "test/audioModule.test.mjs",
26
44
  "test/updateReadme.test.mjs",
45
+ "test/toolsSchema.test.mjs",
46
+ "test/symbolRegistry.test.mjs",
47
+ "test/programRunnersModule.test.mjs",
48
+ "test/batchAndDisplayState.test.mjs",
49
+ "test/inputModule.test.mjs",
27
50
  "test/viceIntegration.test.mjs",
28
51
  ];
29
52
 
@@ -50,11 +73,20 @@ export async function main() {
50
73
  const allTestFiles = await listRepoTestFiles(testRoot);
51
74
  const isolatedTestSet = new Set(isolatedTests);
52
75
  const defaultTestFiles = allTestFiles.filter((f) => !isolatedTestSet.has(f));
53
- const coverageBatches = buildCoverageBatches(defaultTestFiles, [...supplementalTests, ...isolatedTests], process.env);
54
76
  for (const leg of legs) {
55
77
  const legDir = path.join(coverageDir, "matrix", leg.name);
56
78
  await fs.mkdir(legDir, { recursive: true });
57
79
 
80
+ const legDefaultFiles = leg.name === "vice-mock"
81
+ ? DEFAULT_VICE_MOCK_TEST_FILES
82
+ : leg.name === "vice-device"
83
+ ? DEFAULT_VICE_DEVICE_TEST_FILES
84
+ : defaultTestFiles;
85
+ const legSupplementalTests = leg.name.startsWith("vice")
86
+ ? []
87
+ : [...supplementalTests, ...isolatedTests];
88
+ const coverageBatches = buildCoverageBatches(legDefaultFiles, legSupplementalTests, process.env);
89
+
58
90
  const reports = [];
59
91
  for (const batch of coverageBatches) {
60
92
  reports.push(await runCoverageLeg(leg, legDir, batch.label, batch.files));
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  export const DEFAULT_TARGET = "mock";
9
9
 
10
10
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
- const registerDataUri = "data:text/javascript,import { register } from \"node:module\"; import { pathToFileURL } from \"node:url\"; register(\"ts-node/esm\", pathToFileURL(\"./\"));";
11
+ const tsLoader = "tsx/esm";
12
12
  const { nodeArgs, target, explicitBaseUrl } = buildNodeTestArgs(process.argv.slice(2));
13
13
  const defaultEmbeddingsDir = path.join(repoRoot, "artifacts", "test-embeddings");
14
14
  if (!fs.existsSync(defaultEmbeddingsDir)) {
@@ -180,7 +180,7 @@ function isMainModule(moduleUrl) {
180
180
 
181
181
  export function buildNodeTestArgs(args, root = repoRoot) {
182
182
  const defaultTestFiles = listRepoTestFiles(path.join(root, "test"), root);
183
- const nodeArgs = ["--import", registerDataUri, "--test"];
183
+ const nodeArgs = ["--import", tsLoader, "--test"];
184
184
  let target = DEFAULT_TARGET;
185
185
  let explicitBaseUrl = null;
186
186
  let hasExplicitTestFiles = false;
@@ -9,14 +9,79 @@ import { fileURLToPath } from "node:url";
9
9
  const DEFAULT_TARGET = "mock";
10
10
  const DEFAULT_PLATFORM = "c64u";
11
11
  const DEFAULT_BUN_FILE_LIMIT = 4;
12
- const DEFAULT_BUN_BATCH_SIZE = 12;
12
+ // Keep default Bun shards small enough to avoid descriptor exhaustion on broad suites.
13
+ const DEFAULT_BUN_BATCH_SIZE = DEFAULT_BUN_FILE_LIMIT;
14
+ const DEFAULT_VICE_MOCK_TEST_FILES = [
15
+ "test/device.test.mjs",
16
+ "test/viceIntegration.test.mjs",
17
+ "test/viceModule.test.mjs",
18
+ "test/groupedToolsShims.test.mjs",
19
+ "test/inputModule.test.mjs",
20
+ "test/batchAndDisplayState.test.mjs",
21
+ "test/toolsTypes.test.mjs",
22
+ "test/platformRegistry.test.mjs",
23
+ "test/meta/program.test.mjs",
24
+ "test/mcpServerIntegration.test.mjs",
25
+ "test/c64Client.test.mjs",
26
+ "test/vice/viceSmokeTest.ts",
27
+ ];
28
+ const DEFAULT_VICE_DEVICE_TEST_FILES = [
29
+ "test/device.test.mjs",
30
+ "test/vice/viceSmokeTest.ts",
31
+ ];
32
+ const BUN_ONLY_TEST_IMPORT_RE = /from\s+["']bun:test["']/;
33
+ const BUN_RUNTIME_REQUIRED_TEST_FILES = new Set([
34
+ "test/generateMcpInterface.test.mjs",
35
+ "test/scripts/start.test.mjs",
36
+ ]);
37
+ const ISOLATED_NODE_TEST_FILES = new Set([
38
+ "test/mcpServerIntegration.test.mjs",
39
+ "test/mcpServerPlatformInit.test.mjs",
40
+ "test/meta/background.test.mjs",
41
+ "test/pollIntegration.test.mjs",
42
+ "test/pollValidator.test.mjs",
43
+ "test/programRunnersModule.test.mjs",
44
+ "test/toolsCoverage.test.mjs",
45
+ ]);
13
46
  const ISOLATED_BUN_TEST_FILES = new Set([
14
47
  "test/audioRuntime.test.mjs",
48
+ "test/mcpServerIntegration.test.mjs",
49
+ "test/mcpServerPlatformInit.test.mjs",
15
50
  ]);
16
51
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
52
  const defaultEmbeddingsDir = path.join(repoRoot, "artifacts", "test-embeddings");
18
53
  const defaultTestFiles = listRepoTestFiles(path.join(repoRoot, "test"));
19
54
 
55
+ function findExecutableOnPath(binary: string, envPath: string | undefined = process.env.PATH): string | null {
56
+ const normalized = binary.trim();
57
+ if (!normalized) {
58
+ return null;
59
+ }
60
+ if (path.isAbsolute(normalized) || normalized.includes(path.sep)) {
61
+ try {
62
+ fs.accessSync(normalized, fs.constants.X_OK);
63
+ return normalized;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ for (const entry of (envPath ?? "").split(path.delimiter)) {
70
+ if (!entry) {
71
+ continue;
72
+ }
73
+ const candidate = path.join(entry, normalized);
74
+ try {
75
+ fs.accessSync(candidate, fs.constants.X_OK);
76
+ return candidate;
77
+ } catch {
78
+ // Continue searching PATH.
79
+ }
80
+ }
81
+
82
+ return null;
83
+ }
84
+
20
85
  function resolveNodeExecutable(): string {
21
86
  const candidates = [
22
87
  process.env.C64BRIDGE_TEST_NODE_BIN,
@@ -35,6 +100,31 @@ function resolveNodeExecutable(): string {
35
100
  return "node";
36
101
  }
37
102
 
103
+ function resolveBunExecutable(env: NodeJS.ProcessEnv = process.env): string | null {
104
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
105
+ return process.execPath;
106
+ }
107
+
108
+ const candidates = [
109
+ env.C64BRIDGE_TEST_BUN_BIN,
110
+ env.C64BRIDGE_BUN_BIN,
111
+ process.env.C64BRIDGE_TEST_BUN_BIN,
112
+ process.env.C64BRIDGE_BUN_BIN,
113
+ "bun",
114
+ ];
115
+
116
+ for (const candidate of candidates) {
117
+ if (candidate && typeof candidate === "string" && candidate.trim()) {
118
+ const resolved = findExecutableOnPath(candidate.trim(), env.PATH ?? process.env.PATH);
119
+ if (resolved) {
120
+ return resolved;
121
+ }
122
+ }
123
+ }
124
+
125
+ return null;
126
+ }
127
+
38
128
  export type RunTestsArgs = {
39
129
  target: string;
40
130
  platform: "c64u" | "vice";
@@ -43,6 +133,53 @@ export type RunTestsArgs = {
43
133
  passthrough: string[];
44
134
  };
45
135
 
136
+ export function buildMatrixEnv(
137
+ platform: "c64u" | "vice",
138
+ target: "mock" | "device",
139
+ explicitBaseUrl: string | null,
140
+ envSource: NodeJS.ProcessEnv = process.env,
141
+ ): Record<string, string> {
142
+ const env: Record<string, string> = { ...envSource } as Record<string, string>;
143
+ env.C64_MODE = platform;
144
+ env.C64_TEST_TARGET = target === "device" ? "real" : "mock";
145
+
146
+ if (platform === "vice") {
147
+ const useViceMock = target !== "device";
148
+ env.VICE_TEST_TARGET = useViceMock ? "mock" : "vice";
149
+ env.C64_TEST_ENABLE_VICE_MOCK = useViceMock ? "1" : "0";
150
+ if (!useViceMock) {
151
+ env.VICE_AVAILABLE = "1";
152
+ }
153
+ } else {
154
+ delete env.VICE_TEST_TARGET;
155
+ delete env.C64_TEST_ENABLE_VICE_MOCK;
156
+ }
157
+
158
+ if (!env.RAG_EMBEDDINGS_DIR) {
159
+ env.RAG_EMBEDDINGS_DIR = defaultEmbeddingsDir;
160
+ }
161
+ if (explicitBaseUrl) {
162
+ env.C64_TEST_BASE_URL = explicitBaseUrl;
163
+ }
164
+ if (platform === "c64u" && target === "device" && !env.C64_TEST_BASE_URL) {
165
+ env.C64_TEST_BASE_URL = resolveBaseUrlFromConfig(env) ?? "http://c64u";
166
+ }
167
+
168
+ return env;
169
+ }
170
+
171
+ export function resolveDefaultMatrixTestFiles(
172
+ platform: "c64u" | "vice",
173
+ target: "mock" | "device",
174
+ ): string[] {
175
+ if (platform !== "vice") {
176
+ return [...defaultTestFiles];
177
+ }
178
+ return target === "device"
179
+ ? [...DEFAULT_VICE_DEVICE_TEST_FILES]
180
+ : [...DEFAULT_VICE_MOCK_TEST_FILES];
181
+ }
182
+
46
183
  export function parseRunTestsArgs(args: string[]): RunTestsArgs {
47
184
  let target = DEFAULT_TARGET;
48
185
  let platform: "c64u" | "vice" = DEFAULT_PLATFORM;
@@ -106,6 +243,10 @@ export function shouldUseNodeFallback(runCoverage: boolean, passthrough: string[
106
243
  if (runCoverage) {
107
244
  return false;
108
245
  }
246
+ if (String(env.C64_MODE ?? "").trim().toLowerCase() === "vice"
247
+ && String(env.VICE_TEST_TARGET ?? "").trim().toLowerCase() === "vice") {
248
+ return true;
249
+ }
109
250
 
110
251
  const explicitFiles = passthrough.filter(looksLikeTestFileArg);
111
252
  if (passthrough.length === 0) {
@@ -139,11 +280,72 @@ export function buildBunTestBatches(passthrough: string[], env: NodeJS.ProcessEn
139
280
  ];
140
281
  }
141
282
 
283
+ export function splitPassthroughByRuntime(
284
+ passthrough: string[],
285
+ root: string = repoRoot,
286
+ ): { nodePassthrough: string[]; bunPassthrough: string[] } {
287
+ const explicitFiles = passthrough.filter(looksLikeTestFileArg);
288
+ if (explicitFiles.length === 0) {
289
+ return { nodePassthrough: [...passthrough], bunPassthrough: [] };
290
+ }
291
+
292
+ const sharedArgs = passthrough.filter((arg) => !looksLikeTestFileArg(arg));
293
+ const bunFiles = explicitFiles.filter((file) => isBunOnlyTestFile(path.join(root, file)));
294
+ const nodeFiles = explicitFiles.filter((file) => !bunFiles.includes(file));
295
+
296
+ return {
297
+ nodePassthrough: nodeFiles.length > 0 ? [...nodeFiles, ...sharedArgs] : [],
298
+ bunPassthrough: bunFiles.length > 0 ? [...bunFiles, ...sharedArgs] : [],
299
+ };
300
+ }
301
+
302
+ export function buildNodeFallbackBatches(
303
+ passthrough: string[],
304
+ root: string = repoRoot,
305
+ ): string[][] {
306
+ const explicitFiles = passthrough.filter(looksLikeTestFileArg);
307
+ if (explicitFiles.length === 0) {
308
+ return [passthrough];
309
+ }
310
+
311
+ const sharedArgs = passthrough.filter((arg) => !looksLikeTestFileArg(arg));
312
+ const isolatedFiles = explicitFiles.filter((file) => isNodeIsolatedTestFile(path.join(root, file)));
313
+ const sharedFiles = explicitFiles.filter((file) => !isolatedFiles.includes(file));
314
+ const batches: string[][] = [];
315
+
316
+ if (sharedFiles.length > 0) {
317
+ batches.push([...sharedFiles, ...sharedArgs]);
318
+ }
319
+ for (const file of isolatedFiles) {
320
+ batches.push([file, ...sharedArgs]);
321
+ }
322
+
323
+ return batches.length > 0 ? batches : [passthrough];
324
+ }
325
+
326
+ function isBunOnlyTestFile(filePath: string): boolean {
327
+ const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
328
+ if (BUN_RUNTIME_REQUIRED_TEST_FILES.has(relativePath)) {
329
+ return true;
330
+ }
331
+ try {
332
+ return BUN_ONLY_TEST_IMPORT_RE.test(fs.readFileSync(filePath, "utf8"));
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
338
+ function isNodeIsolatedTestFile(filePath: string): boolean {
339
+ const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
340
+ return ISOLATED_NODE_TEST_FILES.has(relativePath);
341
+ }
342
+
142
343
  async function runNodeFallback(target: string, explicitBaseUrl: string | null, passthrough: string[], env: Record<string, string>, runCoverage: boolean): Promise<number> {
143
344
  console.warn("[run-tests] Using Node runner for this test set to avoid Bun memory growth on broad suites");
144
345
  if (runCoverage) {
145
346
  console.warn("[run-tests] Coverage reporting is unavailable in Node fallback mode");
146
347
  }
348
+ const { nodePassthrough, bunPassthrough } = splitPassthroughByRuntime(passthrough);
147
349
  const nodeExecutable = resolveNodeExecutable();
148
350
  const nodeScript = path.join(repoRoot, "scripts", "run-tests.mjs");
149
351
  const fallbackArgs = [] as string[];
@@ -153,23 +355,53 @@ async function runNodeFallback(target: string, explicitBaseUrl: string | null, p
153
355
  if (explicitBaseUrl) {
154
356
  fallbackArgs.push(`--base-url=${explicitBaseUrl}`);
155
357
  }
156
- fallbackArgs.push(...passthrough);
157
- return await new Promise<number>((resolve) => {
158
- const childProcess = spawn(nodeExecutable, [nodeScript, ...fallbackArgs], {
159
- cwd: repoRoot,
160
- env,
161
- stdio: "inherit",
162
- }) as unknown as {
163
- on(event: "error", listener: (error: Error) => void): void;
164
- on(event: "exit", listener: (code: number | null) => void): void;
165
- };
166
- childProcess.on("error", (error) => {
167
- console.error("[run-tests] Failed to launch Node fallback:", error);
168
- resolve(1);
169
- });
170
- childProcess.on("exit", (code) => {
171
- resolve(typeof code === "number" ? code : 1);
172
- });
358
+ if (nodePassthrough.length > 0 || bunPassthrough.length === 0) {
359
+ const nodeBatches = buildNodeFallbackBatches(nodePassthrough);
360
+ for (let index = 0; index < nodeBatches.length; index += 1) {
361
+ const batch = nodeBatches[index] ?? [];
362
+ if (nodeBatches.length > 1) {
363
+ console.warn(`[run-tests] Node fallback batch ${index + 1}/${nodeBatches.length} (${batch.filter(looksLikeTestFileArg).length} files)`);
364
+ }
365
+ const nodeExitCode = await new Promise<number>((resolve) => {
366
+ const childProcess = spawn(nodeExecutable, [nodeScript, ...fallbackArgs, ...batch], {
367
+ cwd: repoRoot,
368
+ env,
369
+ stdio: "inherit",
370
+ }) as unknown as {
371
+ on(event: "error", listener: (error: Error) => void): void;
372
+ on(event: "exit", listener: (code: number | null) => void): void;
373
+ };
374
+ childProcess.on("error", (error) => {
375
+ console.error("[run-tests] Failed to launch Node fallback:", error);
376
+ resolve(1);
377
+ });
378
+ childProcess.on("exit", (code) => {
379
+ resolve(typeof code === "number" ? code : 1);
380
+ });
381
+ });
382
+
383
+ if (nodeExitCode !== 0) {
384
+ return nodeExitCode;
385
+ }
386
+ }
387
+ }
388
+
389
+ if (bunPassthrough.length === 0) {
390
+ return 0;
391
+ }
392
+
393
+ const bunExecutable = resolveBunExecutable(env);
394
+ if (!bunExecutable) {
395
+ console.error("[run-tests] Bun-only tests were selected but no Bun executable is available");
396
+ return 1;
397
+ }
398
+
399
+ console.warn("[run-tests] Running Bun-only tests under Bun to preserve runtime coverage");
400
+ const bunBatches = buildBunTestBatches(bunPassthrough, env);
401
+ return await runBunBatches(env, bunBatches, {
402
+ coverage: false,
403
+ bunExecutable,
404
+ labelPrefix: "bun-only-suite",
173
405
  });
174
406
  }
175
407
 
@@ -200,24 +432,11 @@ async function main(): Promise<number> {
200
432
  fs.mkdirSync(defaultEmbeddingsDir, { recursive: true });
201
433
  }
202
434
 
203
- const env: Record<string, string> = { ...process.env } as Record<string, string>;
204
435
  const normalizedTarget = normalizeTarget(target);
205
- env.C64_MODE = platform;
206
- env.C64_TEST_TARGET = normalizedTarget === "device" ? "real" : "mock";
207
- if (platform === "vice") {
208
- env.VICE_TEST_TARGET = normalizedTarget === "device" ? "vice" : "mock";
209
- } else {
210
- delete env.VICE_TEST_TARGET;
211
- }
212
- if (!env.RAG_EMBEDDINGS_DIR) {
213
- env.RAG_EMBEDDINGS_DIR = defaultEmbeddingsDir;
214
- }
215
- if (explicitBaseUrl) {
216
- env.C64_TEST_BASE_URL = explicitBaseUrl;
217
- }
218
- if (platform === "c64u" && normalizedTarget === "device" && !env.C64_TEST_BASE_URL) {
219
- env.C64_TEST_BASE_URL = resolveBaseUrlFromConfig(env) ?? "http://c64u";
220
- }
436
+ const env = buildMatrixEnv(platform, normalizedTarget, explicitBaseUrl);
437
+ const effectivePassthrough = passthrough.length > 0
438
+ ? passthrough
439
+ : resolveDefaultMatrixTestFiles(platform, normalizedTarget);
221
440
 
222
441
  printMatrixHeading({
223
442
  platform,
@@ -230,24 +449,20 @@ async function main(): Promise<number> {
230
449
  const bunRuntime = (globalThis as { Bun?: unknown }).Bun;
231
450
  if (!bunRuntime) {
232
451
  console.warn("[run-tests] Bun runtime not detected; falling back to Node runner");
233
- return await runNodeFallback(target, explicitBaseUrl, passthrough, env, runCoverage);
452
+ return await runNodeFallback(target, explicitBaseUrl, effectivePassthrough, env, runCoverage);
234
453
  }
235
454
 
236
- if (!runCoverage && passthrough.length === 0) {
237
- return await runBunBatches(env, buildBunTestBatches([], env), {
238
- coverage: false,
239
- labelPrefix: "default-suite",
240
- });
455
+ if (shouldUseNodeFallback(runCoverage, effectivePassthrough, env)) {
456
+ return await runNodeFallback(target, explicitBaseUrl, effectivePassthrough, env, runCoverage);
241
457
  }
242
458
 
243
- if (shouldUseNodeFallback(runCoverage, passthrough, env)) {
244
- if (!runCoverage) {
245
- return await runBunBatches(env, buildBunTestBatches(passthrough, env), {
246
- coverage: false,
247
- labelPrefix: "sharded-suite",
248
- });
249
- }
250
- return await runNodeFallback(target, explicitBaseUrl, passthrough, env, runCoverage);
459
+ if (!runCoverage) {
460
+ const batches = buildBunTestBatches(effectivePassthrough, env);
461
+ return await runBunBatches(env, batches, {
462
+ bunExecutable: process.execPath,
463
+ coverage: false,
464
+ labelPrefix: passthrough.length === 0 ? "default-suite" : "sharded-suite",
465
+ });
251
466
  }
252
467
 
253
468
  const bunArgs = [
@@ -259,7 +474,7 @@ async function main(): Promise<number> {
259
474
  "--coverage-reporter=text",
260
475
  ]
261
476
  : []),
262
- ...(passthrough.length > 0 ? passthrough : defaultTestFiles),
477
+ ...effectivePassthrough.map((arg) => normalizeBunBatchArg(arg)),
263
478
  ];
264
479
  return await runExternalCommand(process.execPath, bunArgs, env);
265
480
  }
@@ -345,7 +560,7 @@ function chunkFiles(files: string[], chunkSize: number): string[][] {
345
560
  async function runBunBatches(
346
561
  env: Record<string, string>,
347
562
  batches: string[][],
348
- options: { coverage: boolean; labelPrefix: string },
563
+ options: { coverage: boolean; labelPrefix: string; bunExecutable: string },
349
564
  ): Promise<number> {
350
565
  const coverageArgs = options.coverage
351
566
  ? ["--coverage", "--coverage-reporter=lcov", "--coverage-reporter=text"]
@@ -354,7 +569,8 @@ async function runBunBatches(
354
569
  for (let index = 0; index < batches.length; index += 1) {
355
570
  const batch = batches[index] ?? [];
356
571
  console.log(`[run-tests] ${options.labelPrefix} batch ${index + 1}/${batches.length} (${batch.length} entries)`);
357
- const exitCode = await runExternalCommand(process.execPath, ["test", ...coverageArgs, ...batch], env);
572
+ const bunArgs = batch.map((arg) => normalizeBunBatchArg(arg));
573
+ const exitCode = await runExternalCommand(options.bunExecutable, ["test", ...coverageArgs, ...bunArgs], env);
358
574
  if (exitCode !== 0) {
359
575
  return exitCode;
360
576
  }
@@ -363,6 +579,13 @@ async function runBunBatches(
363
579
  return 0;
364
580
  }
365
581
 
582
+ export function normalizeBunBatchArg(arg: string): string {
583
+ if (looksLikeTestFileArg(arg) && !arg.startsWith("./") && !arg.startsWith("../") && !path.isAbsolute(arg)) {
584
+ return `./${arg}`;
585
+ }
586
+ return arg;
587
+ }
588
+
366
589
  function listRepoTestFiles(testRoot: string): string[] {
367
590
  const files: string[] = [];
368
591