@isentinel/jest-roblox 0.1.1 → 0.1.3

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.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-DqZE3wME.mjs";
1
+ import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-D6BzDfQ_.mjs";
2
2
 
3
3
  //#region src/cli.d.ts
4
4
  declare function parseArgs(args: Array<string>): CliOptions;
package/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
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-C0_-YIAY.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 formatCompactMultiProject, 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-BU-9pJ93.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { type } from "arktype";
4
4
  import assert from "node:assert";
@@ -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.1";
26
+ var version = "0.1.3";
27
27
  //#endregion
28
28
  //#region src/backends/auto.ts
29
29
  var StudioWithFallback = class {
@@ -115,10 +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 collectPaths(node, result) {
120
- for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
121
- 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;
156
+ }
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 });
184
+ }
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;
122
207
  }
123
208
  //#endregion
124
209
  //#region src/config/projects.ts
@@ -220,6 +305,8 @@ function resolveProjectConfig(project, rootConfig, rojoTree) {
220
305
  };
221
306
  }
222
307
  async function loadProjectConfigFile(filePath, cwd) {
308
+ const luauConfigPath = findLuauConfigFile(filePath, cwd);
309
+ if (luauConfigPath !== void 0) return buildProjectConfigFromLuau(luauConfigPath, filePath);
223
310
  let result;
224
311
  try {
225
312
  result = await loadConfig({
@@ -261,6 +348,38 @@ function mergeProjectConfig(rootConfig, project) {
261
348
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
262
349
  return merged;
263
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
+ }
264
383
  function matchNodePath(childNode, targetPath, childDataModelPath) {
265
384
  const nodePath = childNode.$path;
266
385
  if (typeof nodePath !== "string") return;
@@ -336,6 +455,8 @@ function serializeToLuau(config) {
336
455
  }
337
456
  function generateProjectConfigs(projects) {
338
457
  for (const project of projects) {
458
+ const luauConfigPath = project.outputPath.replace(/\.lua$/, ".luau");
459
+ if (fs.existsSync(luauConfigPath)) continue;
339
460
  const content = serializeToLuau(project.config);
340
461
  const directory = path.dirname(project.outputPath);
341
462
  fs.mkdirSync(directory, { recursive: true });
@@ -441,27 +562,133 @@ function mapCoverageToTypeScript(coverageData, manifest) {
441
562
  if (record === void 0) continue;
442
563
  const resources = loadFileResources(record);
443
564
  if (resources === void 0) continue;
444
- mapFileFunctions(resources, fileCoverage, pendingFunctions, mapFileStatements(resources, fileCoverage, pendingStatements));
445
- 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
+ }
446
577
  }
447
578
  return buildResult(pendingStatements, pendingFunctions, pendingBranches);
448
579
  }
449
580
  function loadFileResources(record) {
450
581
  let coverageMapRaw;
451
- let sourceMapRaw;
452
582
  try {
453
583
  coverageMapRaw = fs$1.readFileSync(record.coverageMapPath, "utf-8");
454
- sourceMapRaw = fs$1.readFileSync(record.sourceMapPath, "utf-8");
455
584
  } catch {
456
585
  return;
457
586
  }
458
587
  const parsed = coverageMapSchema(JSON.parse(coverageMapRaw));
459
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 {}
460
593
  return {
461
594
  coverageMap: parsed,
462
- traceMap: new TraceMap(sourceMapRaw)
595
+ sourceKey: record.key,
596
+ traceMap
463
597
  };
464
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
+ }
465
692
  function mapStatement(traceMap, span) {
466
693
  const mappedStart = originalPositionFor(traceMap, {
467
694
  column: Math.max(0, span.start.column - 1),
@@ -702,26 +929,7 @@ function buildResult(pending, pendingFunctions, pendingBranches) {
702
929
  return { files };
703
930
  }
704
931
  //#endregion
705
- //#region src/types/luau-ast.ts
706
- const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
707
- "assign",
708
- "break",
709
- "compoundassign",
710
- "conditional",
711
- "continue",
712
- "do",
713
- "expression",
714
- "for",
715
- "forin",
716
- "function",
717
- "local",
718
- "localfunction",
719
- "repeat",
720
- "return",
721
- "while"
722
- ]);
723
- //#endregion
724
- //#region src/coverage/luau-visitor.ts
932
+ //#region src/luau/visitor.ts
725
933
  function visitExpression(expression, visitor) {
726
934
  if (visitor.visitExpr?.(expression) === false) return;
727
935
  const { tag } = expression;
@@ -992,6 +1200,23 @@ function visitExprInstantiate(node, visitor) {
992
1200
  }
993
1201
  //#endregion
994
1202
  //#region src/coverage/coverage-collector.ts
1203
+ const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
1204
+ "assign",
1205
+ "break",
1206
+ "compoundassign",
1207
+ "conditional",
1208
+ "continue",
1209
+ "do",
1210
+ "expression",
1211
+ "for",
1212
+ "forin",
1213
+ "function",
1214
+ "local",
1215
+ "localfunction",
1216
+ "repeat",
1217
+ "return",
1218
+ "while"
1219
+ ]);
995
1220
  const END_KEYWORD_LENGTH = 3;
996
1221
  function collectCoverage(root) {
997
1222
  let statementIndex = 1;
@@ -1231,9 +1456,6 @@ function buildCoverageMap$1(result) {
1231
1456
  };
1232
1457
  }
1233
1458
  //#endregion
1234
- //#region src/coverage/parse-ast.luau
1235
- 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";
1236
- //#endregion
1237
1459
  //#region src/coverage/probe-inserter.ts
1238
1460
  function insertProbes(source, result, fileKey) {
1239
1461
  const lines = splitLines(source);
@@ -1288,7 +1510,7 @@ function applyProbes(mutableLines, probes) {
1288
1510
  assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
1289
1511
  const before = line.slice(0, column - 1);
1290
1512
  const after = line.slice(column - 1);
1291
- mutableLines[lineIndex] = before + text + after;
1513
+ mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
1292
1514
  }
1293
1515
  }
1294
1516
  function extractModeDirective(lines) {
@@ -1653,8 +1875,12 @@ function writeManifest(manifestPath, allFiles, luauRoots, placeFile) {
1653
1875
  function buildRojoProject(rojoProjectPath, roots, placeFile) {
1654
1876
  const rojoProjectRaw = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
1655
1877
  if (rojoProjectRaw instanceof type.errors) throw new Error(`Malformed Rojo project JSON: ${rojoProjectRaw.toString()}`);
1656
- const rewritten = rewriteRojoProject(rojoProjectRaw, {
1657
- projectRelocation: path$1.relative(COVERAGE_DIR, path$1.dirname(rojoProjectPath)).replaceAll("\\", "/"),
1878
+ const projectRelocation = path$1.relative(COVERAGE_DIR, path$1.dirname(rojoProjectPath)).replaceAll("\\", "/");
1879
+ const rewritten = rewriteRojoProject({
1880
+ ...rojoProjectRaw,
1881
+ tree: resolveNestedProjects(rojoProjectRaw.tree, path$1.dirname(rojoProjectPath))
1882
+ }, {
1883
+ projectRelocation,
1658
1884
  roots
1659
1885
  });
1660
1886
  const rewrittenProjectPath = path$1.join(COVERAGE_DIR, path$1.basename(rojoProjectPath));
@@ -1798,7 +2024,7 @@ function globSync(pattern, options = {}) {
1798
2024
  return walkDirectory(cwd, cwd).filter((file) => matchesGlobPattern(file, pattern));
1799
2025
  }
1800
2026
  function matchesGlobPattern(filePath, pattern) {
1801
- const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{DOUBLESTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{DOUBLESTAR\}\}/g, ".*");
2027
+ const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*\*\//g, "{{DOUBLESTAR_SLASH}}").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\{\{DOUBLESTAR_SLASH\}\}/g, "(.+/)?");
1802
2028
  return new RegExp(`^${regexPattern}$`).test(filePath);
1803
2029
  }
1804
2030
  function walkDirectory(directoryPath, baseDirectory) {
@@ -1833,6 +2059,7 @@ Options:
1833
2059
  --gameOutput <path> Write game output (print/warn/error) to file
1834
2060
  --sourceMap Map Luau stack traces to TypeScript source
1835
2061
  --rojoProject <path> Path to rojo project file (auto-detected if not set)
2062
+ --passWithNoTests Exit with 0 when no test files are found
1836
2063
  --verbose Show individual test results
1837
2064
  --silent Suppress output
1838
2065
  --no-color Disable colored output
@@ -1894,6 +2121,7 @@ function parseArgs(args) {
1894
2121
  "no-color": { type: "boolean" },
1895
2122
  "no-show-luau": { type: "boolean" },
1896
2123
  "outputFile": { type: "string" },
2124
+ "passWithNoTests": { type: "boolean" },
1897
2125
  "pollInterval": { type: "string" },
1898
2126
  "port": { type: "string" },
1899
2127
  "project": {
@@ -1949,6 +2177,7 @@ function parseArgs(args) {
1949
2177
  gameOutput: values.gameOutput,
1950
2178
  help: values.help,
1951
2179
  outputFile: values.outputFile,
2180
+ passWithNoTests: values.passWithNoTests,
1952
2181
  pollInterval,
1953
2182
  port,
1954
2183
  project: values.project,
@@ -2095,7 +2324,11 @@ function printFinalStatus(passed) {
2095
2324
  process.stdout.write(`${badge}\n`);
2096
2325
  }
2097
2326
  function processCoverage(config, coverageData) {
2098
- if (!config.collectCoverage || coverageData === void 0) return true;
2327
+ if (!config.collectCoverage) return true;
2328
+ if (coverageData === void 0) {
2329
+ if (!config.silent) process.stderr.write("Warning: coverage data was empty — the Rojo project may point at uninstrumented source\n");
2330
+ return true;
2331
+ }
2099
2332
  const manifest = loadCoverageManifest(config.rootDir);
2100
2333
  if (manifest === void 0) {
2101
2334
  if (!config.silent) process.stderr.write("Warning: Coverage manifest not found, skipping TS mapping\n");
@@ -2277,7 +2510,7 @@ function loadRojoTree(config) {
2277
2510
  const content = fs$1.readFileSync(rojoPath, "utf8");
2278
2511
  const validated = rojoProjectSchema(JSON.parse(content));
2279
2512
  if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
2280
- return validated.tree;
2513
+ return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
2281
2514
  }
2282
2515
  const STUB_SKIP_KEYS = new Set([
2283
2516
  "outDir",
@@ -2386,6 +2619,7 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
2386
2619
  tsconfig: rootConfig.typecheckTsconfig
2387
2620
  }) : void 0;
2388
2621
  if (projectResults.length === 0 && typecheckResult === void 0) {
2622
+ if (rootConfig.passWithNoTests) return 0;
2389
2623
  console.error("No test files found in any project");
2390
2624
  return 2;
2391
2625
  }
@@ -2414,12 +2648,14 @@ async function runSingleProject(config, cliFiles) {
2414
2648
  resolveSetupFilePaths(config);
2415
2649
  const discovery = discoverTestFiles(config, cliFiles);
2416
2650
  if (discovery.files.length === 0) {
2651
+ if (config.passWithNoTests) return 0;
2417
2652
  console.error("No test files found");
2418
2653
  return 2;
2419
2654
  }
2420
2655
  const typeTestFiles = config.typecheck ? discovery.files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
2421
2656
  const runtimeTestFiles = config.typecheckOnly ? [] : discovery.files.filter((file) => !TYPE_TEST_PATTERN.test(file));
2422
2657
  if (typeTestFiles.length === 0 && runtimeTestFiles.length === 0) {
2658
+ if (config.passWithNoTests) return 0;
2423
2659
  console.error("No test files found for the selected mode");
2424
2660
  return 2;
2425
2661
  }
@@ -2531,6 +2767,7 @@ function mergeCliWithConfig(cli, config) {
2531
2767
  formatters: resolveFormatters(cli, config),
2532
2768
  gameOutput: cli.gameOutput ?? config.gameOutput,
2533
2769
  outputFile: cli.outputFile ?? config.outputFile,
2770
+ passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
2534
2771
  pollInterval: cli.pollInterval ?? config.pollInterval,
2535
2772
  port: cli.port ?? config.port,
2536
2773
  rojoProject: cli.rojoProject ?? config.rojoProject,
@@ -825,6 +825,7 @@ interface Config extends Except<Argv, "projects" | "rootDir" | "setupFiles" | "s
825
825
  gameOutput?: string;
826
826
  jestPath?: string;
827
827
  luauRoots?: Array<string>;
828
+ passWithNoTests?: boolean;
828
829
  placeFile?: string;
829
830
  pollInterval?: number;
830
831
  port?: number;
@@ -852,6 +853,7 @@ interface ResolvedConfig extends Except<Config, "projects"> {
852
853
  coverageDirectory: string;
853
854
  coveragePathIgnorePatterns: Array<string>;
854
855
  coverageReporters: Array<CoverageReporter>;
856
+ passWithNoTests: boolean;
855
857
  placeFile: string;
856
858
  pollInterval: number;
857
859
  port: number;
@@ -884,6 +886,7 @@ interface CliOptions {
884
886
  gameOutput?: string;
885
887
  help?: boolean;
886
888
  outputFile?: string;
889
+ passWithNoTests?: boolean;
887
890
  pollInterval?: number;
888
891
  port?: number;
889
892
  project?: Array<string>;