@isentinel/jest-roblox 0.1.2 → 0.1.4

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/cli.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import { D as createOpenCloudBackend, I as isValidBackend, L as LuauScriptError, M as ROOT_ONLY_KEYS, N as VALID_BACKENDS, O as hashBuffer, S as loadConfig$1, T as createStudioBackend, _ as formatResult, a as formatAnnotations, b as formatBanner, c as execute, d as findFormatterOptions, g as formatMultiProjectResult, i as runTypecheck, l as formatExecuteOutput, m as formatCompactMultiProject, n as parseGameOutput, o as formatJobSummary, p as writeJsonFile, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, u as loadCoverageManifest, x as rojoProjectSchema, y as formatTypecheckSummary } from "./game-output-BMGxhjkE.mjs";
1
+ import { A as hashBuffer, C as resolveNestedProjects, D as createStudioBackend, F as VALID_BACKENDS, P as ROOT_ONLY_KEYS, R as isValidBackend, S as collectPaths, _ as formatResult, a as formatAnnotations, b as formatBanner, c as execute, d as findFormatterOptions, g as formatMultiProjectResult, i as runTypecheck, k as createOpenCloudBackend, l as formatExecuteOutput, m as formatAgentMultiProject, n as parseGameOutput, o as formatJobSummary, p as writeJsonFile, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, u as loadCoverageManifest, w as loadConfig$1, x as rojoProjectSchema, y as formatTypecheckSummary, z as LuauScriptError } from "./game-output-BL7u7qMT.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { type } from "arktype";
4
4
  import assert from "node:assert";
5
5
  import * as fs$1 from "node:fs";
6
- import fs, { readFileSync } from "node:fs";
6
+ import fs from "node:fs";
7
7
  import * as path$1 from "node:path";
8
- import path, { dirname, join } from "node:path";
8
+ import path from "node:path";
9
9
  import process from "node:process";
10
10
  import { parseArgs as parseArgs$1 } from "node:util";
11
11
  import { isAgent } from "std-env";
@@ -14,16 +14,16 @@ import { WebSocketServer } from "ws";
14
14
  import { loadConfig } from "c12";
15
15
  import * as os from "node:os";
16
16
  import { Buffer } from "node:buffer";
17
+ import * as cp from "node:child_process";
17
18
  import { RojoResolver } from "@roblox-ts/rojo-resolver";
18
19
  import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
19
20
  import { getTsconfig } from "get-tsconfig";
20
21
  import picomatch from "picomatch";
21
- import * as cp from "node:child_process";
22
22
  import istanbulCoverage from "istanbul-lib-coverage";
23
23
  import istanbulReport from "istanbul-lib-report";
24
24
  import istanbulReports from "istanbul-reports";
25
25
  //#region package.json
26
- var version = "0.1.2";
26
+ var version = "0.1.4";
27
27
  //#endregion
28
28
  //#region src/backends/auto.ts
29
29
  var StudioWithFallback = class {
@@ -115,39 +115,95 @@ function stripTsExtension(pattern) {
115
115
  return pattern.replace(/\.(tsx?|luau?)$/, "");
116
116
  }
117
117
  //#endregion
118
- //#region src/utils/rojo-tree.ts
119
- function resolveNestedProjects(tree, rootDirectory) {
120
- return resolveTree(tree, rootDirectory, /* @__PURE__ */ new Set());
121
- }
122
- function collectPaths(node, result) {
123
- for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
124
- else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
118
+ //#region src/luau/eval-literals.ts
119
+ /**
120
+ * Evaluate the first return expression in a Lute-stripped AST root block,
121
+ * supporting only literal values (string, boolean, number, nil, table, cast).
122
+ *
123
+ * Accepts `unknown` and narrows safely no type casts on JSON.parse needed.
124
+ */
125
+ function evalLuauReturnLiterals(root) {
126
+ if (!isObject(root) || !Array.isArray(root["statements"])) throw new Error("Config file has no return statement");
127
+ const returnStat = root["statements"].find((stat) => isObject(stat) && stat["tag"] === "return");
128
+ if (!isObject(returnStat) || !Array.isArray(returnStat["expressions"])) throw new Error("Config file has no return statement");
129
+ const first = returnStat["expressions"][0];
130
+ if (!isObject(first) || !("node" in first)) throw new Error("Return statement has no expressions");
131
+ return evalExpr(first["node"]);
132
+ }
133
+ function isObject(value) {
134
+ return typeof value === "object" && value !== null;
135
+ }
136
+ function evalExpr(node) {
137
+ if (!isObject(node)) return;
138
+ let current = node;
139
+ while (current["tag"] === "cast" && isObject(current["operand"])) current = current["operand"];
140
+ const { tag } = current;
141
+ if (tag === "boolean" || tag === "number") return current["value"];
142
+ if (tag === "string") return current["text"];
143
+ if (tag === "table" && Array.isArray(current["entries"])) return evalTable(current["entries"]);
144
+ }
145
+ function evalTable(entries) {
146
+ if (entries.length === 0) return {};
147
+ const first = entries[0];
148
+ if (isObject(first) && first["kind"] === "list") return entries.map((entry) => isObject(entry) ? evalExpr(entry["value"]) : void 0);
149
+ const result = {};
150
+ for (const entry of entries) {
151
+ if (!isObject(entry) || entry["kind"] !== "record") continue;
152
+ const { key, value } = entry;
153
+ if (isObject(key) && typeof key["text"] === "string") result[key["text"]] = evalExpr(value);
154
+ }
155
+ return result;
125
156
  }
126
- function resolveTree(node, rootDirectory, visited) {
127
- const resolved = {};
128
- for (const [key, value] of Object.entries(node)) {
129
- if (key === "$path" && typeof value === "string" && value.endsWith(".project.json")) {
130
- const projectPath = join(rootDirectory, value);
131
- if (visited.has(projectPath)) throw new Error(`Circular project reference: ${value}`);
132
- const chain = new Set(visited);
133
- chain.add(projectPath);
134
- let content;
135
- try {
136
- content = readFileSync(projectPath, "utf-8");
137
- } catch (err) {
138
- throw new Error(`Could not read nested Rojo project: ${value}`, { cause: err });
139
- }
140
- const innerTree = resolveTree(JSON.parse(content).tree, dirname(projectPath), chain);
141
- for (const [innerKey, innerValue] of Object.entries(innerTree)) resolved[innerKey] = innerValue;
142
- continue;
143
- }
144
- if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
145
- resolved[key] = value;
146
- continue;
147
- }
148
- resolved[key] = resolveTree(value, rootDirectory, visited);
157
+ //#endregion
158
+ //#region src/luau/parse-ast.luau
159
+ var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nif not luauRoot then\n error(\"Usage: lute run parse-ast.luau -- <file.luau | luau-root> [output-dir]\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n binary = { \"lhsoperand\", \"rhsoperand\" },\n block = { \"statements\" },\n boolean = { \"value\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenblock\", \"elseifs\", \"elseblock\", \"thenexpr\", \"elseexpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n number = { \"value\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n string = { \"text\" },\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginline, no tag\n if value.beginline ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Single-file mode: parse one file, print stripped AST to stdout\nif string.sub(luauRoot, -5) == \".luau\" or string.sub(luauRoot, -4) == \".lua\" then\n local source = fs.readfiletostring(luauRoot)\n local parseResult = syntax.parse(source)\n print(json.serialize(strip(parseResult.root)))\n return\nend\n\nlocal outputDir = userArgs[2]\nif not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listdirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox-coverage\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readfiletostring(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createdirectory(dir, { makeparents = true })\n end\n\n fs.writestringtofile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.array))\n";
160
+ //#endregion
161
+ //#region src/config/luau-config-loader.ts
162
+ let cachedTemporaryDirectory$1;
163
+ /**
164
+ * Parse a .luau config file via Lute and evaluate its return expression.
165
+ */
166
+ function loadLuauConfig(filePath) {
167
+ const temporaryDirectory = getTemporaryDirectory$1();
168
+ const scriptPath = path$1.join(temporaryDirectory, "parse-ast.luau");
169
+ fs$1.writeFileSync(scriptPath, parse_ast_default);
170
+ let stdout;
171
+ try {
172
+ stdout = cp.execFileSync("lute", [
173
+ "run",
174
+ scriptPath,
175
+ "--",
176
+ path$1.resolve(filePath)
177
+ ], {
178
+ encoding: "utf-8",
179
+ maxBuffer: 1024 * 1024
180
+ });
181
+ } catch (err) {
182
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new Error("lute is required to load .luau config files but was not found on PATH");
183
+ throw new Error(`Failed to evaluate Luau config ${filePath}`, { cause: err });
149
184
  }
150
- return resolved;
185
+ let ast;
186
+ try {
187
+ ast = JSON.parse(stdout);
188
+ } catch (err) {
189
+ throw new Error(`Failed to parse AST JSON from Luau config ${filePath}`, { cause: err });
190
+ }
191
+ const result = evalLuauReturnLiterals(ast);
192
+ if (typeof result !== "object" || result === null) throw new Error(`Luau config ${filePath} must return a table`);
193
+ return result;
194
+ }
195
+ /**
196
+ * Check if `<cwd>/<directoryOrFile>/jest.config.luau` exists. Returns the
197
+ * resolved path if found, undefined otherwise.
198
+ */
199
+ function findLuauConfigFile(directoryOrFile, cwd) {
200
+ const resolved = path$1.resolve(cwd, directoryOrFile, "jest.config.luau");
201
+ if (fs$1.existsSync(resolved)) return resolved;
202
+ }
203
+ function getTemporaryDirectory$1() {
204
+ if (cachedTemporaryDirectory$1 !== void 0 && fs$1.existsSync(cachedTemporaryDirectory$1)) return cachedTemporaryDirectory$1;
205
+ cachedTemporaryDirectory$1 = fs$1.mkdtempSync(path$1.join(os.tmpdir(), "jest-roblox-luau-config-"));
206
+ return cachedTemporaryDirectory$1;
151
207
  }
152
208
  //#endregion
153
209
  //#region src/config/projects.ts
@@ -249,6 +305,8 @@ function resolveProjectConfig(project, rootConfig, rojoTree) {
249
305
  };
250
306
  }
251
307
  async function loadProjectConfigFile(filePath, cwd) {
308
+ const luauConfigPath = findLuauConfigFile(filePath, cwd);
309
+ if (luauConfigPath !== void 0) return buildProjectConfigFromLuau(luauConfigPath, filePath);
252
310
  let result;
253
311
  try {
254
312
  result = await loadConfig({
@@ -290,6 +348,38 @@ function mergeProjectConfig(rootConfig, project) {
290
348
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
291
349
  return merged;
292
350
  }
351
+ const LUAU_BOOLEAN_KEYS = [
352
+ "automock",
353
+ "clearMocks",
354
+ "injectGlobals",
355
+ "mockDataModel",
356
+ "resetMocks",
357
+ "resetModules",
358
+ "restoreMocks"
359
+ ];
360
+ const LUAU_NUMBER_KEYS = ["slowTestThreshold", "testTimeout"];
361
+ const LUAU_STRING_KEYS = ["testEnvironment"];
362
+ const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
363
+ function copyLuauOptionalFields(raw, config) {
364
+ const record = config;
365
+ for (const key of LUAU_BOOLEAN_KEYS) if (typeof raw[key] === "boolean") record[key] = raw[key];
366
+ for (const key of LUAU_NUMBER_KEYS) if (typeof raw[key] === "number") record[key] = raw[key];
367
+ for (const key of LUAU_STRING_KEYS) if (typeof raw[key] === "string") record[key] = raw[key];
368
+ for (const key of LUAU_STRING_ARRAY_KEYS) if (Array.isArray(raw[key])) record[key] = raw[key];
369
+ }
370
+ function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
371
+ const raw = loadLuauConfig(luauConfigPath);
372
+ const { displayName } = raw;
373
+ if (typeof displayName !== "string" || displayName === "") throw new Error(`Luau config file "${luauConfigPath}" must have a displayName string`);
374
+ const testMatch = Array.isArray(raw["testMatch"]) ? raw["testMatch"] : void 0;
375
+ const config = {
376
+ displayName,
377
+ include: testMatch !== void 0 ? testMatch.map((pattern) => path$1.posix.join(directoryPath, `${pattern}.luau`)) : [path$1.posix.join(directoryPath, "**/*.spec.luau")]
378
+ };
379
+ if (testMatch !== void 0) config.testMatch = testMatch;
380
+ copyLuauOptionalFields(raw, config);
381
+ return config;
382
+ }
293
383
  function matchNodePath(childNode, targetPath, childDataModelPath) {
294
384
  const nodePath = childNode.$path;
295
385
  if (typeof nodePath !== "string") return;
@@ -365,6 +455,8 @@ function serializeToLuau(config) {
365
455
  }
366
456
  function generateProjectConfigs(projects) {
367
457
  for (const project of projects) {
458
+ const luauConfigPath = project.outputPath.replace(/\.lua$/, ".luau");
459
+ if (fs.existsSync(luauConfigPath)) continue;
368
460
  const content = serializeToLuau(project.config);
369
461
  const directory = path.dirname(project.outputPath);
370
462
  fs.mkdirSync(directory, { recursive: true });
@@ -470,27 +562,133 @@ function mapCoverageToTypeScript(coverageData, manifest) {
470
562
  if (record === void 0) continue;
471
563
  const resources = loadFileResources(record);
472
564
  if (resources === void 0) continue;
473
- mapFileFunctions(resources, fileCoverage, pendingFunctions, mapFileStatements(resources, fileCoverage, pendingStatements));
474
- mapFileBranches(resources, fileCoverage, pendingBranches);
565
+ if (resources.traceMap === void 0) {
566
+ passthroughFileStatements(resources, fileCoverage, pendingStatements);
567
+ passthroughFileFunctions(resources, fileCoverage, pendingFunctions);
568
+ passthroughFileBranches(resources, fileCoverage, pendingBranches);
569
+ } else {
570
+ const mapped = {
571
+ coverageMap: resources.coverageMap,
572
+ traceMap: resources.traceMap
573
+ };
574
+ mapFileFunctions(mapped, fileCoverage, pendingFunctions, mapFileStatements(mapped, fileCoverage, pendingStatements));
575
+ mapFileBranches(mapped, fileCoverage, pendingBranches);
576
+ }
475
577
  }
476
578
  return buildResult(pendingStatements, pendingFunctions, pendingBranches);
477
579
  }
478
580
  function loadFileResources(record) {
479
581
  let coverageMapRaw;
480
- let sourceMapRaw;
481
582
  try {
482
583
  coverageMapRaw = fs$1.readFileSync(record.coverageMapPath, "utf-8");
483
- sourceMapRaw = fs$1.readFileSync(record.sourceMapPath, "utf-8");
484
584
  } catch {
485
585
  return;
486
586
  }
487
587
  const parsed = coverageMapSchema(JSON.parse(coverageMapRaw));
488
588
  if (parsed instanceof type.errors) return;
589
+ let traceMap;
590
+ try {
591
+ traceMap = new TraceMap(fs$1.readFileSync(record.sourceMapPath, "utf-8"));
592
+ } catch {}
489
593
  return {
490
594
  coverageMap: parsed,
491
- traceMap: new TraceMap(sourceMapRaw)
595
+ sourceKey: record.key,
596
+ traceMap
492
597
  };
493
598
  }
599
+ function toIstanbulColumn(luauColumn) {
600
+ return Math.max(0, luauColumn - 1);
601
+ }
602
+ function passthroughFileStatements(resources, fileCoverage, pending) {
603
+ let fileStatements = pending.get(resources.sourceKey);
604
+ if (fileStatements === void 0) {
605
+ fileStatements = /* @__PURE__ */ new Map();
606
+ pending.set(resources.sourceKey, fileStatements);
607
+ }
608
+ for (const [statementId, rawSpan] of Object.entries(resources.coverageMap.statementMap)) {
609
+ const span = spanSchema(rawSpan);
610
+ if (span instanceof type.errors) continue;
611
+ const hitCount = fileCoverage.s[statementId] ?? 0;
612
+ fileStatements.set(statementId, {
613
+ end: {
614
+ column: toIstanbulColumn(span.end.column),
615
+ line: span.end.line
616
+ },
617
+ hitCount,
618
+ start: {
619
+ column: toIstanbulColumn(span.start.column),
620
+ line: span.start.line
621
+ }
622
+ });
623
+ }
624
+ }
625
+ function passthroughFileFunctions(resources, fileCoverage, pendingFunctions) {
626
+ if (resources.coverageMap.functionMap === void 0) return;
627
+ let fileFunctions = pendingFunctions.get(resources.sourceKey);
628
+ if (fileFunctions === void 0) {
629
+ fileFunctions = [];
630
+ pendingFunctions.set(resources.sourceKey, fileFunctions);
631
+ }
632
+ for (const [functionId, rawEntry] of Object.entries(resources.coverageMap.functionMap)) {
633
+ const entry = functionEntrySchema(rawEntry);
634
+ if (entry instanceof type.errors) continue;
635
+ fileFunctions.push({
636
+ name: entry.name,
637
+ hitCount: fileCoverage.f?.[functionId] ?? 0,
638
+ loc: {
639
+ end: {
640
+ column: toIstanbulColumn(entry.location.end.column),
641
+ line: entry.location.end.line
642
+ },
643
+ start: {
644
+ column: toIstanbulColumn(entry.location.start.column),
645
+ line: entry.location.start.line
646
+ }
647
+ }
648
+ });
649
+ }
650
+ }
651
+ function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
652
+ if (resources.coverageMap.branchMap === void 0) return;
653
+ let fileBranches = pendingBranches.get(resources.sourceKey);
654
+ if (fileBranches === void 0) {
655
+ fileBranches = [];
656
+ pendingBranches.set(resources.sourceKey, fileBranches);
657
+ }
658
+ for (const [branchId, rawEntry] of Object.entries(resources.coverageMap.branchMap)) {
659
+ const entry = branchEntrySchema(rawEntry);
660
+ if (entry instanceof type.errors) continue;
661
+ const armHitCounts = fileCoverage.b?.[branchId] ?? [];
662
+ const locations = [];
663
+ for (const rawLocation of entry.locations) {
664
+ const location = spanSchema(rawLocation);
665
+ if (location instanceof type.errors) continue;
666
+ locations.push({
667
+ end: {
668
+ column: toIstanbulColumn(location.end.column),
669
+ line: location.end.line
670
+ },
671
+ start: {
672
+ column: toIstanbulColumn(location.start.column),
673
+ line: location.start.line
674
+ }
675
+ });
676
+ }
677
+ if (locations.length === 0) continue;
678
+ const firstLocation = locations[0];
679
+ const lastLocation = locations[locations.length - 1];
680
+ assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
681
+ fileBranches.push({
682
+ armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
683
+ loc: {
684
+ end: lastLocation.end,
685
+ start: firstLocation.start
686
+ },
687
+ locations,
688
+ type: entry.type
689
+ });
690
+ }
691
+ }
494
692
  function mapStatement(traceMap, span) {
495
693
  const mappedStart = originalPositionFor(traceMap, {
496
694
  column: Math.max(0, span.start.column - 1),
@@ -731,26 +929,47 @@ function buildResult(pending, pendingFunctions, pendingBranches) {
731
929
  return { files };
732
930
  }
733
931
  //#endregion
734
- //#region src/types/luau-ast.ts
735
- const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
736
- "assign",
737
- "break",
738
- "compoundassign",
739
- "conditional",
740
- "continue",
741
- "do",
742
- "expression",
743
- "for",
744
- "forin",
745
- "function",
746
- "local",
747
- "localfunction",
748
- "repeat",
749
- "return",
750
- "while"
751
- ]);
932
+ //#region src/coverage/merge-raw-coverage.ts
933
+ /**
934
+ * Additively merge two raw coverage datasets. Overlapping files have their
935
+ * hit counts summed (matching istanbul-lib-coverage's semantics).
936
+ */
937
+ function mergeRawCoverage(target, source) {
938
+ if (target === void 0) return source;
939
+ if (source === void 0) return target;
940
+ const result = { ...target };
941
+ for (const [filePath, fileCoverage] of Object.entries(source)) {
942
+ const existing = result[filePath];
943
+ result[filePath] = existing === void 0 ? { ...fileCoverage } : mergeFileCoverage(existing, fileCoverage);
944
+ }
945
+ return result;
946
+ }
947
+ function sumScalars(a, b) {
948
+ const result = { ...a };
949
+ for (const [key, value] of Object.entries(b)) result[key] = (result[key] ?? 0) + value;
950
+ return result;
951
+ }
952
+ function sumBranches(a, b) {
953
+ const result = { ...a };
954
+ for (const [key, bArms] of Object.entries(b)) {
955
+ const aArms = result[key];
956
+ if (aArms === void 0) {
957
+ result[key] = [...bArms];
958
+ continue;
959
+ }
960
+ const length = Math.max(aArms.length, bArms.length);
961
+ result[key] = Array.from({ length }, (_, index) => (aArms[index] ?? 0) + (bArms[index] ?? 0));
962
+ }
963
+ return result;
964
+ }
965
+ function mergeFileCoverage(a, b) {
966
+ const merged = { s: sumScalars(a.s, b.s) };
967
+ if (a.f !== void 0 || b.f !== void 0) merged.f = sumScalars(a.f ?? {}, b.f ?? {});
968
+ if (a.b !== void 0 || b.b !== void 0) merged.b = sumBranches(a.b ?? {}, b.b ?? {});
969
+ return merged;
970
+ }
752
971
  //#endregion
753
- //#region src/coverage/luau-visitor.ts
972
+ //#region src/luau/visitor.ts
754
973
  function visitExpression(expression, visitor) {
755
974
  if (visitor.visitExpr?.(expression) === false) return;
756
975
  const { tag } = expression;
@@ -1021,6 +1240,23 @@ function visitExprInstantiate(node, visitor) {
1021
1240
  }
1022
1241
  //#endregion
1023
1242
  //#region src/coverage/coverage-collector.ts
1243
+ const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
1244
+ "assign",
1245
+ "break",
1246
+ "compoundassign",
1247
+ "conditional",
1248
+ "continue",
1249
+ "do",
1250
+ "expression",
1251
+ "for",
1252
+ "forin",
1253
+ "function",
1254
+ "local",
1255
+ "localfunction",
1256
+ "repeat",
1257
+ "return",
1258
+ "while"
1259
+ ]);
1024
1260
  const END_KEYWORD_LENGTH = 3;
1025
1261
  function collectCoverage(root) {
1026
1262
  let statementIndex = 1;
@@ -1260,9 +1496,6 @@ function buildCoverageMap$1(result) {
1260
1496
  };
1261
1497
  }
1262
1498
  //#endregion
1263
- //#region src/coverage/parse-ast.luau
1264
- var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nlocal outputDir = userArgs[2]\nif not luauRoot or not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n binary = { \"lhsoperand\", \"rhsoperand\" },\n block = { \"statements\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenblock\", \"elseifs\", \"elseblock\", \"thenexpr\", \"elseexpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginline, no tag\n if value.beginline ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listdirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox-coverage\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readfiletostring(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createdirectory(dir, { makeparents = true })\n end\n\n fs.writestringtofile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.array))\n";
1265
- //#endregion
1266
1499
  //#region src/coverage/probe-inserter.ts
1267
1500
  function insertProbes(source, result, fileKey) {
1268
1501
  const lines = splitLines(source);
@@ -1317,7 +1550,7 @@ function applyProbes(mutableLines, probes) {
1317
1550
  assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
1318
1551
  const before = line.slice(0, column - 1);
1319
1552
  const after = line.slice(column - 1);
1320
- mutableLines[lineIndex] = before + text + after;
1553
+ mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
1321
1554
  }
1322
1555
  }
1323
1556
  function extractModeDirective(lines) {
@@ -1743,7 +1976,7 @@ function printCoverageHeader() {
1743
1976
  process.stdout.write(`\n${header}\n`);
1744
1977
  }
1745
1978
  function generateReports(options) {
1746
- const coverageMap = buildCoverageMap(options.mapped);
1979
+ const coverageMap = buildCoverageMap(filterMappedFiles(options.mapped, options.collectCoverageFrom));
1747
1980
  const context = istanbulReport.createContext({
1748
1981
  coverageMap,
1749
1982
  dir: options.coverageDirectory
@@ -1753,8 +1986,8 @@ function generateReports(options) {
1753
1986
  istanbulReports.create(reporterName).execute(context);
1754
1987
  }
1755
1988
  }
1756
- function checkThresholds(mapped, thresholds) {
1757
- const summary = buildCoverageMap(mapped).getCoverageSummary();
1989
+ function checkThresholds(mapped, thresholds, collectCoverageFrom) {
1990
+ const summary = buildCoverageMap(filterMappedFiles(mapped, collectCoverageFrom)).getCoverageSummary();
1758
1991
  const failures = [];
1759
1992
  const checks = [
1760
1993
  {
@@ -1821,6 +2054,26 @@ function buildCoverageMap(mapped) {
1821
2054
  }
1822
2055
  return coverageMap;
1823
2056
  }
2057
+ function createGlobMatcher(patterns) {
2058
+ const withPath = patterns.filter((pattern) => pattern.includes("/"));
2059
+ const withoutPath = patterns.filter((pattern) => !pattern.includes("/"));
2060
+ const matchers = [];
2061
+ if (withPath.length > 0) matchers.push(picomatch(withPath));
2062
+ if (withoutPath.length > 0) matchers.push(picomatch(withoutPath, { matchBase: true }));
2063
+ return (filePath) => matchers.some((matcher) => matcher(filePath));
2064
+ }
2065
+ function filterMappedFiles(mapped, collectCoverageFrom) {
2066
+ if (collectCoverageFrom === void 0 || collectCoverageFrom.length === 0) return mapped;
2067
+ const includePatterns = collectCoverageFrom.filter((pattern) => !pattern.startsWith("!"));
2068
+ const excludePatterns = collectCoverageFrom.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
2069
+ const isIncluded = includePatterns.length > 0 ? createGlobMatcher(includePatterns) : () => true;
2070
+ const isExcluded = excludePatterns.length > 0 ? createGlobMatcher(excludePatterns) : () => false;
2071
+ const cwd = process.cwd();
2072
+ return { files: Object.fromEntries(Object.entries(mapped.files).filter(([filePath]) => {
2073
+ const relativePath = path$1.isAbsolute(filePath) ? path$1.relative(cwd, filePath).replaceAll("\\", "/") : filePath;
2074
+ return isIncluded(relativePath) && !isExcluded(relativePath);
2075
+ })) };
2076
+ }
1824
2077
  function isValidReporter(name) {
1825
2078
  return VALID_REPORTERS.has(name);
1826
2079
  }
@@ -1866,11 +2119,13 @@ Options:
1866
2119
  --gameOutput <path> Write game output (print/warn/error) to file
1867
2120
  --sourceMap Map Luau stack traces to TypeScript source
1868
2121
  --rojoProject <path> Path to rojo project file (auto-detected if not set)
2122
+ --passWithNoTests Exit with 0 when no test files are found
1869
2123
  --verbose Show individual test results
1870
2124
  --silent Suppress output
1871
2125
  --no-color Disable colored output
1872
2126
  -u, --updateSnapshot Update snapshot files
1873
2127
  --coverage Enable coverage collection
2128
+ --collectCoverageFrom <glob> Globs for files to include in coverage (repeatable)
1874
2129
  --coverageDirectory <path> Directory for coverage output (default: coverage)
1875
2130
  --coverageReporters <r...> Coverage reporters (default: text, lcov)
1876
2131
  --formatters <name...> Output formatters (default, agent, json, github-actions)
@@ -1906,6 +2161,10 @@ function parseArgs(args) {
1906
2161
  options: {
1907
2162
  "backend": { type: "string" },
1908
2163
  "cache": { type: "boolean" },
2164
+ "collectCoverageFrom": {
2165
+ multiple: true,
2166
+ type: "string"
2167
+ },
1909
2168
  "color": { type: "boolean" },
1910
2169
  "config": { type: "string" },
1911
2170
  "coverage": { type: "boolean" },
@@ -1927,6 +2186,7 @@ function parseArgs(args) {
1927
2186
  "no-color": { type: "boolean" },
1928
2187
  "no-show-luau": { type: "boolean" },
1929
2188
  "outputFile": { type: "string" },
2189
+ "passWithNoTests": { type: "boolean" },
1930
2190
  "pollInterval": { type: "string" },
1931
2191
  "port": { type: "string" },
1932
2192
  "project": {
@@ -1973,6 +2233,7 @@ function parseArgs(args) {
1973
2233
  backend: validateBackend(values.backend),
1974
2234
  cache: values["no-cache"] === true ? false : values.cache,
1975
2235
  collectCoverage: values.coverage,
2236
+ collectCoverageFrom: values.collectCoverageFrom,
1976
2237
  color: values["no-color"] === true ? false : values.color,
1977
2238
  config: values.config,
1978
2239
  coverageDirectory: values.coverageDirectory,
@@ -1982,6 +2243,7 @@ function parseArgs(args) {
1982
2243
  gameOutput: values.gameOutput,
1983
2244
  help: values.help,
1984
2245
  outputFile: values.outputFile,
2246
+ passWithNoTests: values.passWithNoTests,
1985
2247
  pollInterval,
1986
2248
  port,
1987
2249
  project: values.project,
@@ -2043,10 +2305,7 @@ function mergeProjectResults(results) {
2043
2305
  totalMs += result.timing.totalMs;
2044
2306
  uploadMs += result.timing.uploadMs ?? 0;
2045
2307
  coverageMs += result.timing.coverageMs ?? 0;
2046
- if (result.coverageData !== void 0) mergedCoverage = {
2047
- ...mergedCoverage,
2048
- ...result.coverageData
2049
- };
2308
+ if (result.coverageData !== void 0) mergedCoverage = mergeRawCoverage(mergedCoverage, result.coverageData);
2050
2309
  }
2051
2310
  return {
2052
2311
  coverageData: mergedCoverage,
@@ -2142,12 +2401,13 @@ function processCoverage(config, coverageData) {
2142
2401
  const coverageDirectory = path$1.resolve(config.rootDir, config.coverageDirectory);
2143
2402
  if (!config.silent) printCoverageHeader();
2144
2403
  generateReports({
2404
+ collectCoverageFrom: config.collectCoverageFrom,
2145
2405
  coverageDirectory,
2146
2406
  mapped,
2147
2407
  reporters: config.coverageReporters
2148
2408
  });
2149
2409
  if (config.coverageThreshold !== void 0) {
2150
- const result = checkThresholds(mapped, config.coverageThreshold);
2410
+ const result = checkThresholds(mapped, config.coverageThreshold, config.collectCoverageFrom);
2151
2411
  if (!result.passed) {
2152
2412
  for (const failure of result.failures) process.stderr.write(`Coverage threshold not met for ${failure.metric}: ${String(failure.actual.toFixed(2))}% < ${String(failure.threshold)}%\n`);
2153
2413
  return false;
@@ -2177,7 +2437,7 @@ function runGitHubActionsFormatter(config, result, sourceMapper) {
2177
2437
  function hasFormatter(config, name) {
2178
2438
  return config.formatters?.some((entry) => Array.isArray(entry) ? entry[0] === name : entry === name) === true;
2179
2439
  }
2180
- function getCompactMaxFailures(config) {
2440
+ function getAgentMaxFailures(config) {
2181
2441
  assert(config.formatters !== void 0, "formatters is set by resolveFormatters");
2182
2442
  const options = findFormatterOptions(config.formatters, "agent");
2183
2443
  if (options !== void 0 && typeof options["maxFailures"] === "number") return options["maxFailures"];
@@ -2264,9 +2524,9 @@ function printMultiProjectOutput(options) {
2264
2524
  const { config, merged, preCoverageMs, projectResults, typecheckResult } = options;
2265
2525
  const timing = addCoverageTiming(merged.timing, preCoverageMs);
2266
2526
  if (usesAgentFormatter(config)) {
2267
- printOutput(formatCompactMultiProject(toProjectEntries(projectResults), {
2527
+ printOutput(formatAgentMultiProject(toProjectEntries(projectResults), {
2268
2528
  gameOutput: config.gameOutput,
2269
- maxFailures: getCompactMaxFailures(config),
2529
+ maxFailures: getAgentMaxFailures(config),
2270
2530
  outputFile: config.outputFile,
2271
2531
  rootDir: config.rootDir,
2272
2532
  sourceMapper: merged.sourceMapper,
@@ -2423,6 +2683,7 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
2423
2683
  tsconfig: rootConfig.typecheckTsconfig
2424
2684
  }) : void 0;
2425
2685
  if (projectResults.length === 0 && typecheckResult === void 0) {
2686
+ if (rootConfig.passWithNoTests) return 0;
2426
2687
  console.error("No test files found in any project");
2427
2688
  return 2;
2428
2689
  }
@@ -2451,12 +2712,14 @@ async function runSingleProject(config, cliFiles) {
2451
2712
  resolveSetupFilePaths(config);
2452
2713
  const discovery = discoverTestFiles(config, cliFiles);
2453
2714
  if (discovery.files.length === 0) {
2715
+ if (config.passWithNoTests) return 0;
2454
2716
  console.error("No test files found");
2455
2717
  return 2;
2456
2718
  }
2457
2719
  const typeTestFiles = config.typecheck ? discovery.files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
2458
2720
  const runtimeTestFiles = config.typecheckOnly ? [] : discovery.files.filter((file) => !TYPE_TEST_PATTERN.test(file));
2459
2721
  if (typeTestFiles.length === 0 && runtimeTestFiles.length === 0) {
2722
+ if (config.passWithNoTests) return 0;
2460
2723
  console.error("No test files found for the selected mode");
2461
2724
  return 2;
2462
2725
  }
@@ -2542,16 +2805,9 @@ function validateBackend(value) {
2542
2805
  function getLuauErrorHint(message) {
2543
2806
  for (const [pattern, hint] of LUAU_ERROR_HINTS) if (pattern.test(message)) return hint;
2544
2807
  }
2545
- function normalizeFormatterName(name) {
2546
- return name === "compact" ? "agent" : name;
2547
- }
2548
- function normalizeFormatterEntry(entry) {
2549
- if (Array.isArray(entry)) return [normalizeFormatterName(entry[0]), entry[1]];
2550
- return normalizeFormatterName(entry);
2551
- }
2552
2808
  function resolveFormatters(cli, config) {
2553
2809
  const explicit = cli.formatters ?? config.formatters;
2554
- if (explicit !== void 0) return explicit.map(normalizeFormatterEntry);
2810
+ if (explicit !== void 0) return explicit;
2555
2811
  const defaults = isAgent ? ["agent"] : ["default"];
2556
2812
  if (process.env["GITHUB_ACTIONS"] === "true") defaults.push("github-actions");
2557
2813
  return defaults;
@@ -2562,12 +2818,14 @@ function mergeCliWithConfig(cli, config) {
2562
2818
  backend: cli.backend ?? config.backend,
2563
2819
  cache: cli.cache ?? config.cache,
2564
2820
  collectCoverage: cli.collectCoverage ?? config.collectCoverage,
2821
+ collectCoverageFrom: cli.collectCoverageFrom ?? config.collectCoverageFrom,
2565
2822
  color: cli.color ?? config.color,
2566
2823
  coverageDirectory: cli.coverageDirectory ?? config.coverageDirectory,
2567
2824
  coverageReporters: cli.coverageReporters ?? config.coverageReporters,
2568
2825
  formatters: resolveFormatters(cli, config),
2569
2826
  gameOutput: cli.gameOutput ?? config.gameOutput,
2570
2827
  outputFile: cli.outputFile ?? config.outputFile,
2828
+ passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
2571
2829
  pollInterval: cli.pollInterval ?? config.pollInterval,
2572
2830
  port: cli.port ?? config.port,
2573
2831
  rojoProject: cli.rojoProject ?? config.rojoProject,