@isentinel/jest-roblox 0.3.4 → 0.3.6

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,4 +1,4 @@
1
- import { $ as loadConfig, D as outputMultiResult, H as formatBanner, O as outputSingleResult, P as parseGameOutput, Q as mergeCliWithConfig, Y as LuauScriptError, dt as version, f as formatMissingScopes, lt as isValidBackend, n as runJestRoblox, ot as VALID_BACKENDS, p as walkErrorChain, ut as ConfigError } from "./run-D20euZYa.mjs";
1
+ import { $ as loadConfig, D as outputMultiResult, H as formatBanner, O as outputSingleResult, P as parseGameOutput, Q as mergeCliWithConfig, Y as LuauScriptError, dt as version, f as formatMissingScopes, lt as isValidBackend, n as runJestRoblox, ot as VALID_BACKENDS, p as walkErrorChain, ut as ConfigError } from "./run-C4GuftZH.mjs";
2
2
  import { OpenCloudError } from "@bedrock-rbx/ocale";
3
3
  import process from "node:process";
4
4
  import { parseArgs as parseArgs$1 } from "node:util";
package/dist/index.d.mts CHANGED
@@ -364,6 +364,12 @@ interface RunnerCredentials {
364
364
  }
365
365
  interface UploadPlaceOptions {
366
366
  placeFilePath: string;
367
+ /**
368
+ * Publish as the live version instead of a Saved draft. Open Cloud Luau
369
+ * Execution boots the live Published version on fresh and recycled servers,
370
+ * so a Saved-only upload can be ignored mid-run when a warm server recycles.
371
+ */
372
+ publish?: boolean;
367
373
  }
368
374
  interface UploadPlaceResult {
369
375
  uploadMs: number;
@@ -511,7 +517,7 @@ type ParsedManifest<T> = {
511
517
  * probe-inserter is intentionally not formalized here: it has no serialization
512
518
  * boundary, so the TypeScript interface is sufficient.
513
519
  */
514
- declare const MANIFEST_VERSION: 3;
520
+ declare const MANIFEST_VERSION: 4;
515
521
  interface InstrumentedFileRecord {
516
522
  key: string;
517
523
  branchCount?: number;
@@ -529,6 +535,13 @@ interface InstrumentedFileRecord {
529
535
  sourceHash: string;
530
536
  sourceMapPath: string;
531
537
  statementCount: number;
538
+ /**
539
+ * Statement ids hit during the coverage run but credited to no per-test
540
+ * window (executed at module load or in a hook). Computed by the per-test
541
+ * attribution harvester; a consumer marks a mutant on one of these as
542
+ * Ignored (ADR-0003). Absent on a freshly-instrumented manifest (no run yet).
543
+ */
544
+ staticStatementIds?: Array<string>;
532
545
  }
533
546
  /**
534
547
  * One Jest test case's identity, recorded so Phase 3's differential cache can
@@ -584,6 +597,11 @@ declare function readManifest(filePath: string): ReadManifestResult;
584
597
  interface AttributionResult {
585
598
  /** Per Luau file: statement id → ids of the tests that covered it. */
586
599
  coveringTestIds: Record<string, Record<string, Array<string>>>;
600
+ /**
601
+ * Per Luau file: statement ids hit during the run but credited to no per-test
602
+ * window (module-load or hook code). The static-mutant set of ADR-0003.
603
+ */
604
+ staticStatementIds: Record<string, Array<string>>;
587
605
  tests: Array<TestRecord>;
588
606
  }
589
607
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as loadConfig, A as formatJobSummary, B as formatResult, C as BUILD_MANIFEST_VERSION, E as readBuildManifest, F as writeGameOutput, G as manifestSchema, I as createTimingCollector, J as applyAttribution, K as readManifest, L as formatJson, M as runProjects, N as formatGameOutputNotice, P as parseGameOutput, Q as mergeCliWithConfig, R as writeJsonFile, S as buildPlace, T as emitBuildManifest, U as hashFile, V as formatTestSummary, W as MANIFEST_VERSION, X as extractJsonFromOutput, Z as parseJestOutput, _ as COVERAGE_MANIFEST_PATH, a as loadRojoTree, at as SHARED_TEST_KEYS, b as visitExpression, c as StudioBackend, ct as defineProject, d as createOpenCloudBackend, et as resolveConfig, g as COVERAGE_BUILD_MANIFEST_PATH, h as generateTestScript, i as collectStubMounts, it as ROOT_CLI_KEYS, j as formatExecuteOutput, k as formatAnnotations, l as createStudioBackend, m as buildJestArgv, n as runJestRoblox, nt as GLOBAL_TEST_KEYS, o as runTypecheck, q as writeManifest, r as runSingleOrMulti, rt as JEST_ARGV_EXCLUDED_KEYS, s as resolveAllProjects, st as defineConfig, t as getRawProjects, tt as DEFAULT_CONFIG, u as OpenCloudBackend, v as findRojoProject, w as buildManifestSchema, x as visitStatement, y as visitBlock, z as formatFailure } from "./run-D20euZYa.mjs";
1
+ import { $ as loadConfig, A as formatJobSummary, B as formatResult, C as BUILD_MANIFEST_VERSION, E as readBuildManifest, F as writeGameOutput, G as manifestSchema, I as createTimingCollector, J as applyAttribution, K as readManifest, L as formatJson, M as runProjects, N as formatGameOutputNotice, P as parseGameOutput, Q as mergeCliWithConfig, R as writeJsonFile, S as buildPlace, T as emitBuildManifest, U as hashFile, V as formatTestSummary, W as MANIFEST_VERSION, X as extractJsonFromOutput, Z as parseJestOutput, _ as COVERAGE_MANIFEST_PATH, a as loadRojoTree, at as SHARED_TEST_KEYS, b as visitExpression, c as StudioBackend, ct as defineProject, d as createOpenCloudBackend, et as resolveConfig, g as COVERAGE_BUILD_MANIFEST_PATH, h as generateTestScript, i as collectStubMounts, it as ROOT_CLI_KEYS, j as formatExecuteOutput, k as formatAnnotations, l as createStudioBackend, m as buildJestArgv, n as runJestRoblox, nt as GLOBAL_TEST_KEYS, o as runTypecheck, q as writeManifest, r as runSingleOrMulti, rt as JEST_ARGV_EXCLUDED_KEYS, s as resolveAllProjects, st as defineConfig, t as getRawProjects, tt as DEFAULT_CONFIG, u as OpenCloudBackend, v as findRojoProject, w as buildManifestSchema, x as visitStatement, y as visitBlock, z as formatFailure } from "./run-C4GuftZH.mjs";
2
2
  import * as path$1 from "node:path";
3
3
  //#region src/artifacts/prepare-artifacts.ts
4
4
  const COVERAGE_DIR = path$1.dirname(COVERAGE_BUILD_MANIFEST_PATH);
@@ -34,7 +34,7 @@ import { StorageClient } from "@bedrock-rbx/ocale/storage";
34
34
  import { parseJSONC, parseYAML } from "confbox";
35
35
  import { Visitor, parseSync } from "oxc-parser";
36
36
  //#region package.json
37
- var version = "0.3.4";
37
+ var version = "0.3.6";
38
38
  //#endregion
39
39
  //#region src/config/errors.ts
40
40
  var ConfigError = class extends Error {
@@ -46,7 +46,7 @@ var ConfigError = class extends Error {
46
46
  };
47
47
  //#endregion
48
48
  //#region src/config/schema.ts
49
- const VALID_BACKENDS = new Set([
49
+ const VALID_BACKENDS = /* @__PURE__ */ new Set([
50
50
  "auto",
51
51
  "open-cloud",
52
52
  "studio"
@@ -336,7 +336,7 @@ const PROJECT_TEST_KEYS = new Set(PROJECT_TEST_KEYS_LIST);
336
336
  * by config shape but are consumed by the runner's lute-based coverage layer
337
337
  * (not jest itself).
338
338
  */
339
- const JEST_ARGV_EXCLUDED_KEYS = new Set([
339
+ const JEST_ARGV_EXCLUDED_KEYS = /* @__PURE__ */ new Set([
340
340
  ...ROOT_CLI_KEYS_LIST,
341
341
  "collectCoverage",
342
342
  "collectCoverageFrom",
@@ -510,7 +510,7 @@ async function processExtends(result, visited) {
510
510
  visited.delete(canonicalFile);
511
511
  }
512
512
  }
513
- const EMPTY_ARRAY_DEFAULT_KEYS = new Set([
513
+ const EMPTY_ARRAY_DEFAULT_KEYS = /* @__PURE__ */ new Set([
514
514
  "collectCoverageFrom",
515
515
  "formatters",
516
516
  "luauRoots",
@@ -521,8 +521,8 @@ const EMPTY_ARRAY_DEFAULT_KEYS = new Set([
521
521
  "setupFilesAfterEnv",
522
522
  "snapshotSerializers"
523
523
  ]);
524
- const EMPTY_OBJECT_DEFAULT_KEYS = new Set(["coverageThreshold", "snapshotFormat"]);
525
- const MERGEABLE_KEYS = new Set([
524
+ const EMPTY_OBJECT_DEFAULT_KEYS = /* @__PURE__ */ new Set(["coverageThreshold", "snapshotFormat"]);
525
+ const MERGEABLE_KEYS = /* @__PURE__ */ new Set([
526
526
  ...EMPTY_ARRAY_DEFAULT_KEYS,
527
527
  ...EMPTY_OBJECT_DEFAULT_KEYS,
528
528
  "coveragePathIgnorePatterns",
@@ -1043,7 +1043,7 @@ function populateBranches(file, fileBranches) {
1043
1043
  }
1044
1044
  function buildResult(pending, pendingFunctions, pendingBranches) {
1045
1045
  const files = {};
1046
- const allPaths = new Set([
1046
+ const allPaths = /* @__PURE__ */ new Set([
1047
1047
  ...pending.keys(),
1048
1048
  ...pendingFunctions.keys(),
1049
1049
  ...pendingBranches.keys()
@@ -1141,7 +1141,7 @@ function createGlobMatcher(patterns) {
1141
1141
  }
1142
1142
  //#endregion
1143
1143
  //#region src/coverage-pipeline/reporter.ts
1144
- const VALID_REPORTERS = new Set([
1144
+ const VALID_REPORTERS = /* @__PURE__ */ new Set([
1145
1145
  "clover",
1146
1146
  "cobertura",
1147
1147
  "html",
@@ -1160,7 +1160,7 @@ function printCoverageHeader() {
1160
1160
  const header = ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
1161
1161
  process.stdout.write(`\n${header}\n`);
1162
1162
  }
1163
- const TEXT_REPORTERS = new Set(["text", "text-summary"]);
1163
+ const TEXT_REPORTERS = /* @__PURE__ */ new Set(["text", "text-summary"]);
1164
1164
  function generateReports(options) {
1165
1165
  const coverageMap = buildCoverageMap$2(filterCoverageUniverse(options.mapped, {
1166
1166
  ignore: options.coveragePathIgnorePatterns,
@@ -1463,12 +1463,12 @@ function findInTree(node, targetPath, currentDataModelPath) {
1463
1463
  }
1464
1464
  const LUA_EXT = ".lua";
1465
1465
  const LUAU_EXT = ".luau";
1466
- const ROJO_MODULE_EXTS = new Set([
1466
+ const ROJO_MODULE_EXTS = /* @__PURE__ */ new Set([
1467
1467
  ".json",
1468
1468
  LUAU_EXT,
1469
1469
  ".toml"
1470
1470
  ]);
1471
- const ROJO_SCRIPT_EXTS = new Set([LUAU_EXT]);
1471
+ const ROJO_SCRIPT_EXTS = /* @__PURE__ */ new Set([LUAU_EXT]);
1472
1472
  const INIT_NAME = "init";
1473
1473
  const SERVER_SUB_EXTENSION = ".server";
1474
1474
  const CLIENT_SUB_EXTENSION = ".client";
@@ -1482,7 +1482,7 @@ const RbxType = {
1482
1482
  Script: 1,
1483
1483
  Unknown: 3
1484
1484
  };
1485
- const SUB_EXT_TYPE_MAP = new Map([
1485
+ const SUB_EXT_TYPE_MAP = /* @__PURE__ */ new Map([
1486
1486
  [CLIENT_SUB_EXTENSION, RbxType.LocalScript],
1487
1487
  [MODULE_SUB_EXTENSION, RbxType.ModuleScript],
1488
1488
  [SERVER_SUB_EXTENSION, RbxType.Script]
@@ -2115,10 +2115,12 @@ function buildProjectResult(entry, job, fallbackGameOutput) {
2115
2115
  //#region src/coverage-pipeline/attribution.ts
2116
2116
  /**
2117
2117
  * Assemble the per-test attribution records the coverage manifest carries from
2118
- * the runner's per-test deltas. `resolveSourceHash` maps a test file path to its
2119
- * source hash (an injected seam so the harvester stays pure and fs-free).
2118
+ * the runner's per-test deltas. `cumulative` is the whole-run hit table, used to
2119
+ * derive the static set (statements hit but credited to no test window).
2120
+ * `resolveSourceHash` maps a test file path to its source hash (an injected seam
2121
+ * so the harvester stays pure and fs-free).
2120
2122
  */
2121
- function harvestAttribution(entries, resolveSourceHash) {
2123
+ function harvestAttribution(entries, cumulative, resolveSourceHash) {
2122
2124
  const tests = [];
2123
2125
  const coveringTestIds = {};
2124
2126
  for (const entry of entries) {
@@ -2137,14 +2139,16 @@ function harvestAttribution(entries, resolveSourceHash) {
2137
2139
  }
2138
2140
  return {
2139
2141
  coveringTestIds,
2142
+ staticStatementIds: deriveStatic(cumulative, coveringTestIds),
2140
2143
  tests
2141
2144
  };
2142
2145
  }
2143
2146
  /**
2144
2147
  * Write an attribution result into a coverage manifest: set `tests[]` and place
2145
- * each file's per-statement `coveringTestIds` on its record. Attribution for a
2146
- * file absent from the manifest (e.g. a covered helper outside the report
2147
- * universe) is dropped — the manifest's file set stays the source of truth.
2148
+ * each file's per-statement `coveringTestIds` and `staticStatementIds` on its
2149
+ * record. Attribution for a file absent from the manifest (e.g. a covered helper
2150
+ * outside the report universe) is dropped — the manifest's file set stays the
2151
+ * source of truth.
2148
2152
  */
2149
2153
  function applyAttribution(manifest, attribution) {
2150
2154
  const files = { ...manifest.files };
@@ -2155,6 +2159,13 @@ function applyAttribution(manifest, attribution) {
2155
2159
  coveringTestIds
2156
2160
  };
2157
2161
  }
2162
+ for (const [fileKey, staticStatementIds] of Object.entries(attribution.staticStatementIds)) {
2163
+ const record = files[fileKey];
2164
+ if (record !== void 0) files[fileKey] = {
2165
+ ...record,
2166
+ staticStatementIds
2167
+ };
2168
+ }
2158
2169
  return {
2159
2170
  ...manifest,
2160
2171
  files,
@@ -2162,9 +2173,11 @@ function applyAttribution(manifest, attribution) {
2162
2173
  };
2163
2174
  }
2164
2175
  /**
2165
- * Merge two attribution results: concatenate the test records and union the
2166
- * per-statement covering ids. Used to combine per-project attribution from a
2167
- * multi-project run into the single manifest.
2176
+ * Merge two attribution results: concatenate the test records, union the
2177
+ * per-statement covering ids, and union the static sets. A statement static in
2178
+ * one project but credited to a test in the other is not static across the
2179
+ * merged run, so the union drops any id credited in the merged `coveringTestIds`.
2180
+ * Used to combine per-project attribution from a multi-project run.
2168
2181
  */
2169
2182
  function mergeAttribution(a, b) {
2170
2183
  const coveringTestIds = {};
@@ -2174,9 +2187,34 @@ function mergeAttribution(a, b) {
2174
2187
  }
2175
2188
  return {
2176
2189
  coveringTestIds,
2190
+ staticStatementIds: mergeStatic(a.staticStatementIds, b.staticStatementIds, coveringTestIds),
2177
2191
  tests: [...a.tests, ...b.tests]
2178
2192
  };
2179
2193
  }
2194
+ /**
2195
+ * A statement is static iff it was hit in the run (`count > 0`) but never
2196
+ * credited to a per-test window — i.e. its id is not a key in `coveringTestIds`
2197
+ * for that file. Ids are sorted numerically so the manifest stays stable.
2198
+ */
2199
+ function deriveStatic(cumulative, coveringTestIds) {
2200
+ const staticStatementIds = {};
2201
+ for (const [fileKey, fileCoverage] of Object.entries(cumulative)) {
2202
+ const credited = coveringTestIds[fileKey];
2203
+ const ids = Object.entries(fileCoverage.s).filter(([statementId, hitCount]) => hitCount > 0 && credited?.[statementId] === void 0).map(([statementId]) => statementId).sort((a, b) => Number(a) - Number(b));
2204
+ if (ids.length > 0) staticStatementIds[fileKey] = ids;
2205
+ }
2206
+ return staticStatementIds;
2207
+ }
2208
+ function mergeStatic(a, b, coveringTestIds) {
2209
+ const merged = {};
2210
+ const fileKeys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
2211
+ for (const fileKey of fileKeys) {
2212
+ const credited = coveringTestIds[fileKey];
2213
+ const ids = [.../* @__PURE__ */ new Set([...a[fileKey] ?? [], ...b[fileKey] ?? []])].filter((statementId) => credited?.[statementId] === void 0).sort((first, second) => Number(first) - Number(second));
2214
+ if (ids.length > 0) merged[fileKey] = ids;
2215
+ }
2216
+ return merged;
2217
+ }
2180
2218
  //#endregion
2181
2219
  //#region src/utils/atomic-write.ts
2182
2220
  /**
@@ -2250,7 +2288,7 @@ function parseVersionedManifest(filePath, schema, expectedVersion) {
2250
2288
  * probe-inserter is intentionally not formalized here: it has no serialization
2251
2289
  * boundary, so the TypeScript interface is sufficient.
2252
2290
  */
2253
- const MANIFEST_VERSION = 3;
2291
+ const MANIFEST_VERSION = 4;
2254
2292
  const instrumentedFileRecordSchema = type({
2255
2293
  "key": "string",
2256
2294
  "branchCount?": "number",
@@ -2261,7 +2299,8 @@ const instrumentedFileRecordSchema = type({
2261
2299
  "originalLuauPath": "string",
2262
2300
  "sourceHash": "string",
2263
2301
  "sourceMapPath": "string",
2264
- "statementCount": "number"
2302
+ "statementCount": "number",
2303
+ "staticStatementIds?": "string[]"
2265
2304
  }).as();
2266
2305
  const testRecordSchema = type({
2267
2306
  testCaseId: "string",
@@ -2285,13 +2324,13 @@ const manifestSchema = type({
2285
2324
  "rojoInputsHash?": "string",
2286
2325
  "shadowDir": "string",
2287
2326
  "tests?": testRecordSchema.array(),
2288
- "version": type.unit(3)
2327
+ "version": type.unit(4)
2289
2328
  }).as();
2290
2329
  function writeManifest(filePath, manifest) {
2291
2330
  atomicWrite(filePath, JSON.stringify(manifest, void 0, " "));
2292
2331
  }
2293
2332
  function readManifest(filePath) {
2294
- return parseVersionedManifest(filePath, manifestSchema, 3);
2333
+ return parseVersionedManifest(filePath, manifestSchema, 4);
2295
2334
  }
2296
2335
  //#endregion
2297
2336
  //#region src/utils/hash.ts
@@ -2739,7 +2778,7 @@ const TS_SUPPORTED_EXTS = new Set(["js", "ts"].flatMap((lang) => [
2739
2778
  `.m${lang}x`,
2740
2779
  `.c${lang}x`
2741
2780
  ]));
2742
- const LUAU_SUPPORTED_EXTS = new Set([".lua", ".luau"]);
2781
+ const LUAU_SUPPORTED_EXTS = /* @__PURE__ */ new Set([".lua", ".luau"]);
2743
2782
  function highlightCode(id, source) {
2744
2783
  const extension = extname(id);
2745
2784
  if (LUAU_SUPPORTED_EXTS.has(extension)) return highlightLuau(source);
@@ -4562,7 +4601,8 @@ function processProjectResult(entry, options) {
4562
4601
  const testsMs = calculateTestsMs(result.testResults);
4563
4602
  const sourceMapper = config.sourceMap ? timing.profile("buildSourceMapper", () => buildSourceMapper(config, tsconfigMappings)) : void 0;
4564
4603
  resolveTestFilePaths(result, sourceMapper);
4565
- const attribution = perTestCoverage !== void 0 ? harvestAttribution(perTestCoverage, (testFilePath) => {
4604
+ const harvestStatic = config.collectPerTestCoverage === true && coverageData !== void 0;
4605
+ const attribution = perTestCoverage !== void 0 || harvestStatic ? harvestAttribution(perTestCoverage ?? [], coverageData ?? {}, (testFilePath) => {
4566
4606
  return resolveTestFileHash(sourceMapper, testFilePath);
4567
4607
  }) : void 0;
4568
4608
  const totalMs = Date.now() - startTime;
@@ -5260,7 +5300,7 @@ function redirectPathToShadow(target, coverageRoots) {
5260
5300
  //#region src/staging/synthesizer.ts
5261
5301
  const STUB_INJECTION_KEY = "jest.config";
5262
5302
  const COLLIDING_SOURCE_FILES = ["jest.config.lua", "jest.config.luau"];
5263
- const SERVICE_CLASSES = new Set([
5303
+ const SERVICE_CLASSES = /* @__PURE__ */ new Set([
5264
5304
  "Chat",
5265
5305
  "CollectionService",
5266
5306
  "DataModel",
@@ -5286,7 +5326,7 @@ const SERVICE_CLASSES = new Set([
5286
5326
  "UserInputService",
5287
5327
  "Workspace"
5288
5328
  ]);
5289
- const SERVICE_PROPERTIES = new Set([
5329
+ const SERVICE_PROPERTIES = /* @__PURE__ */ new Set([
5290
5330
  "AutoRuns",
5291
5331
  "ExecuteWithStudioRun",
5292
5332
  "LoadStringEnabled"
@@ -5760,7 +5800,7 @@ function visitExprInstantiate(node, visitor) {
5760
5800
  }
5761
5801
  //#endregion
5762
5802
  //#region src/coverage-pipeline/coverage-collector.ts
5763
- const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
5803
+ const INSTRUMENTABLE_STATEMENT_TAGS = /* @__PURE__ */ new Set([
5764
5804
  "assign",
5765
5805
  "break",
5766
5806
  "compoundassign",
@@ -6246,7 +6286,7 @@ function computeRojoInputsHash(options) {
6246
6286
  const mounts = [];
6247
6287
  collectPaths(tree, mounts);
6248
6288
  const luauRootKeys = luauRoots.map((root) => toKey(path$1.join(rootDirectory, root)));
6249
- const files = new Set([toKey(rojoProjectPath)]);
6289
+ const files = /* @__PURE__ */ new Set([toKey(rojoProjectPath)]);
6250
6290
  for (const projectFile of projectFiles) files.add(toKey(projectFile));
6251
6291
  const visitedDirectories = /* @__PURE__ */ new Set();
6252
6292
  for (const mount of mounts) {
@@ -6321,13 +6361,14 @@ function discoverInstrumentableFiles(luauRoot) {
6321
6361
  return new Set(results);
6322
6362
  }
6323
6363
  /**
6324
- * Populate a shadow dir from one luauRoot: bulk-copy non-instrumented files
6325
- * (cold path), run the instrumenter to overlay instrumented prod files, then
6326
- * sync spec/test/snap files with hash-tracked records so the shadow is a
6327
- * complete mirror that satisfies rojo + testMatch.
6364
+ * Populate a shadow dir from one luauRoot: bulk-copy every file (cold path),
6365
+ * run the instrumenter to overlay instrumented prod files, then sync the files
6366
+ * the instrumenter never emits (spec/test/snap plus non-luau rojo files) with
6367
+ * hash-tracked records so the shadow is a complete mirror that satisfies rojo +
6368
+ * testMatch.
6328
6369
  *
6329
- * On a warm run (cache hit) only changed files are re-instrumented and
6330
- * stale shadow entries are pruned.
6370
+ * On a warm run (cache hit) only changed files are re-instrumented, and the
6371
+ * shadow is reconciled against source so files deleted upstream don't linger.
6331
6372
  */
6332
6373
  function prepareShadowRoot(options) {
6333
6374
  const { luauRoot, previousManifest, shadowDir, useIncremental } = options;
@@ -6360,6 +6401,7 @@ function prepareShadowRoot(options) {
6360
6401
  if (useIncremental && previousManifest !== void 0 && skipFiles !== void 0) carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
6361
6402
  const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDir, previousManifest?.nonInstrumentedFiles);
6362
6403
  if (syncResult.changed) changed = true;
6404
+ if (useIncremental && reconcileShadowToSource(luauRoot, shadowDir)) changed = true;
6363
6405
  return {
6364
6406
  changed,
6365
6407
  files: allFiles,
@@ -6368,16 +6410,17 @@ function prepareShadowRoot(options) {
6368
6410
  shadowDir
6369
6411
  };
6370
6412
  }
6371
- function detectDeletedFiles(previousManifest, currentFiles) {
6372
- const deleted = [];
6373
- for (const [fileKey, record] of Object.entries(previousManifest.files)) if (!(fileKey in currentFiles)) deleted.push(record);
6374
- return deleted;
6375
- }
6376
- function cleanupDeletedFiles(records) {
6377
- for (const record of records) try {
6378
- if (fs$1.existsSync(record.instrumentedLuauPath)) fs$1.unlinkSync(record.instrumentedLuauPath);
6379
- if (fs$1.existsSync(record.coverageMapPath)) fs$1.unlinkSync(record.coverageMapPath);
6380
- } catch {}
6413
+ const COV_MAP_SUFFIX = ".cov-map.json";
6414
+ /**
6415
+ * Does the source file backing a shadow entry still exist? A `.cov-map.json`
6416
+ * sidecar has no direct twin — it is keyed to its base `.luau`/`.lua`.
6417
+ */
6418
+ function sourceTwinExists(luauRoot, relativePath) {
6419
+ if (relativePath.endsWith(COV_MAP_SUFFIX)) {
6420
+ const base = relativePath.slice(0, -13);
6421
+ return fs$1.existsSync(path$1.resolve(luauRoot, `${base}.luau`)) || fs$1.existsSync(path$1.resolve(luauRoot, `${base}.lua`));
6422
+ }
6423
+ return fs$1.existsSync(path$1.resolve(luauRoot, relativePath));
6381
6424
  }
6382
6425
  /**
6383
6426
  * Shared directory walker. Skips node_modules and dot-prefixed directories —
@@ -6398,9 +6441,50 @@ function walkLuauDirectory(directory, relativeTo, predicate, results) {
6398
6441
  }
6399
6442
  }
6400
6443
  }
6444
+ /**
6445
+ * Reconcile a warm shadow dir against its source root: unlink every shadow file
6446
+ * whose source no longer exists. This is the warm-run deletion mechanism across
6447
+ * every file category the pipeline manages — instrumented prod `.luau`,
6448
+ * spec/test/snap, and non-luau rojo files (`init.meta.json`, `*.model.json`, …)
6449
+ * alike. Diffing against source (rather than a recorded file set) means a file
6450
+ * category the sync never tracked still gets cleaned up, so a stale
6451
+ * `init.meta.json` can't survive into the rojo build and fail it. It walks with
6452
+ * the same scope as the rest of the pipeline (`walkLuauDirectory` skips
6453
+ * `node_modules`/dot-dirs); vendored content under those dirs is governed by
6454
+ * `rojoInputsHash` instead, which forces a cold rebuild when it changes.
6455
+ * `.cov-map.json` sidecars are instrumenter output with no 1:1 source twin;
6456
+ * they map back to their base `.luau`/`.lua`. Returns whether anything was
6457
+ * removed, so the caller forces a place rebuild.
6458
+ */
6459
+ function reconcileShadowToSource(luauRoot, shadowDirectory) {
6460
+ if (!fs$1.existsSync(shadowDirectory)) return false;
6461
+ const posixShadow = normalizeWindowsPath(shadowDirectory);
6462
+ const shadowFiles = [];
6463
+ walkLuauDirectory(posixShadow, posixShadow, () => true, shadowFiles);
6464
+ let deleted = false;
6465
+ for (const relativePath of shadowFiles) {
6466
+ if (sourceTwinExists(luauRoot, relativePath)) continue;
6467
+ try {
6468
+ fs$1.unlinkSync(path$1.resolve(shadowDirectory, relativePath));
6469
+ deleted = true;
6470
+ } catch {}
6471
+ }
6472
+ return deleted;
6473
+ }
6401
6474
  function isInstrumentableFile(name) {
6402
6475
  return (name.endsWith(".luau") || name.endsWith(".lua")) && !isNonInstrumentedFile(name);
6403
6476
  }
6477
+ /**
6478
+ * Every file the shadow dir must carry verbatim because the instrumenter never
6479
+ * emits it: spec/test/snap `.luau` plus all non-luau rojo files
6480
+ * (`init.meta.json`, `*.model.json`, …). The complement of
6481
+ * `isInstrumentableFile` — prod `.luau` is excluded because `instrumentRoot`
6482
+ * writes its instrumented copy into the shadow. `.cov-map.json` sidecars are
6483
+ * instrumenter output, not source, so they are excluded too.
6484
+ */
6485
+ function shouldSyncToShadow(name) {
6486
+ return !isInstrumentableFile(name) && !name.endsWith(COV_MAP_SUFFIX);
6487
+ }
6404
6488
  function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
6405
6489
  const posixRoot = normalizeWindowsPath(luauRoot);
6406
6490
  for (const relativePath of skipFiles) {
@@ -6408,26 +6492,13 @@ function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
6408
6492
  Object.assign(allFiles, { [fileKey]: previousManifest.files[fileKey] });
6409
6493
  }
6410
6494
  }
6411
- function discoverNonInstrumentedFiles(directory, relativeTo, results) {
6412
- walkLuauDirectory(directory, relativeTo, isNonInstrumentedFile, results);
6413
- }
6414
- function pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, currentFiles) {
6415
- if (previousNonInstrumented === void 0) return false;
6416
- let changed = false;
6417
- for (const [fileKey, record] of Object.entries(previousNonInstrumented)) {
6418
- if (!fileKey.startsWith(`${posixRoot}/`)) continue;
6419
- if (fileKey in currentFiles) continue;
6420
- try {
6421
- if (fs$1.existsSync(record.shadowPath)) fs$1.unlinkSync(record.shadowPath);
6422
- } catch {}
6423
- changed = true;
6424
- }
6425
- return changed;
6495
+ function discoverShadowSyncFiles(directory, relativeTo, results) {
6496
+ walkLuauDirectory(directory, relativeTo, shouldSyncToShadow, results);
6426
6497
  }
6427
6498
  function syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousNonInstrumented) {
6428
6499
  const posixRoot = normalizeWindowsPath(luauRoot);
6429
6500
  const discovered = [];
6430
- discoverNonInstrumentedFiles(posixRoot, posixRoot, discovered);
6501
+ discoverShadowSyncFiles(posixRoot, posixRoot, discovered);
6431
6502
  const files = {};
6432
6503
  let changed = false;
6433
6504
  for (const relativePath of discovered) {
@@ -6449,7 +6520,6 @@ function syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousNonInstrume
6449
6520
  };
6450
6521
  changed = true;
6451
6522
  }
6452
- changed = pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, files) || changed;
6453
6523
  return {
6454
6524
  changed,
6455
6525
  files
@@ -6503,8 +6573,9 @@ function buildFullCacheResult(options) {
6503
6573
  const allFiles = {};
6504
6574
  carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
6505
6575
  const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousManifest.nonInstrumentedFiles);
6576
+ const reconciled = reconcileShadowToSource(luauRoot, shadowDirectory);
6506
6577
  return {
6507
- changed: syncResult.changed,
6578
+ changed: syncResult.changed || reconciled,
6508
6579
  files: allFiles,
6509
6580
  luauRoot,
6510
6581
  nonInstrumentedFiles: syncResult.files,
@@ -6560,7 +6631,8 @@ function prepareCoverage(config, beforeBuild) {
6560
6631
  const manifestPath = path$1.join(COVERAGE_DIR, COVERAGE_MANIFEST);
6561
6632
  const buildManifestPath = path$1.join(COVERAGE_DIR, BUILD_MANIFEST_FILE);
6562
6633
  const previousManifest = loadCoverageManifest(manifestPath);
6563
- const useIncremental = canUseIncremental$1(previousManifest, config);
6634
+ let useIncremental = canUseIncremental$1(previousManifest, config);
6635
+ if (useIncremental && previousManifest !== void 0 && hasDroppedLuauRoot(previousManifest.luauRoots, luauRoots)) useIncremental = false;
6564
6636
  if (!useIncremental && fs$1.existsSync(COVERAGE_DIR)) fs$1.rmSync(COVERAGE_DIR, { recursive: true });
6565
6637
  const allFiles = {};
6566
6638
  const allNonInstrumented = {};
@@ -6581,11 +6653,6 @@ function prepareCoverage(config, beforeBuild) {
6581
6653
  shadowDir: normalizeWindowsPath(path$1.resolve(result.shadowDir))
6582
6654
  });
6583
6655
  }
6584
- if (useIncremental && previousManifest !== void 0) {
6585
- const deleted = detectDeletedFiles(previousManifest, allFiles);
6586
- cleanupDeletedFiles(deleted);
6587
- if (deleted.length > 0) hasChanges = true;
6588
- }
6589
6656
  if (beforeBuild !== void 0) {
6590
6657
  if (beforeBuild(COVERAGE_DIR)) hasChanges = true;
6591
6658
  }
@@ -6720,7 +6787,7 @@ function buildAndWriteManifest(options) {
6720
6787
  placeFilePath: placeFile,
6721
6788
  rojoInputsHash,
6722
6789
  shadowDir: COVERAGE_DIR,
6723
- version: 3
6790
+ version: 4
6724
6791
  };
6725
6792
  writeManifest(manifestPath, manifest);
6726
6793
  return manifest;
@@ -6763,6 +6830,10 @@ function reuseCoverageResult(options) {
6763
6830
  rebuilt: false
6764
6831
  };
6765
6832
  }
6833
+ function hasDroppedLuauRoot(previous, current) {
6834
+ const currentSet = new Set(current.map(normalizeWindowsPath));
6835
+ return previous.some((root) => !currentSet.has(normalizeWindowsPath(root)));
6836
+ }
6766
6837
  //#endregion
6767
6838
  //#region packages/roblox-runner/dist/index.mjs
6768
6839
  const FIELD_SPECS = [
@@ -6860,7 +6931,8 @@ var OcaleRunner = class {
6860
6931
  placeId: this.credentials.placeId,
6861
6932
  universeId: this.credentials.universeId
6862
6933
  };
6863
- const result = await this.places.save(parameters, { retryableTransportCodes: TRANSIENT_TRANSPORT_CODES });
6934
+ const requestOptions = { retryableTransportCodes: TRANSIENT_TRANSPORT_CODES };
6935
+ const result = options.publish === true ? await this.places.publish(parameters, requestOptions) : await this.places.save(parameters, requestOptions);
6864
6936
  if (!result.success) throw new Error(`Failed to upload place: ${result.err.message}`, { cause: result.err });
6865
6937
  return {
6866
6938
  uploadMs: Date.now() - uploadStart,
@@ -7612,7 +7684,7 @@ function getTemporaryDirectory() {
7612
7684
  //#endregion
7613
7685
  //#region src/config/projects.ts
7614
7686
  function extractStaticRoot(pattern) {
7615
- const globChars = new Set([
7687
+ const globChars = /* @__PURE__ */ new Set([
7616
7688
  "*",
7617
7689
  "?",
7618
7690
  "[",
@@ -7690,7 +7762,7 @@ function validateProjects(projects) {
7690
7762
  if (project.include.length === 0) throw new Error(`Project "${name}" must have at least one include pattern`);
7691
7763
  }
7692
7764
  }
7693
- const PROJECT_ONLY_KEYS = new Set([
7765
+ const PROJECT_ONLY_KEYS = /* @__PURE__ */ new Set([
7694
7766
  "displayName",
7695
7767
  "exclude",
7696
7768
  "include",
@@ -8069,7 +8141,7 @@ function isGeneratedStub(filePath) {
8069
8141
  }
8070
8142
  }
8071
8143
  const LEGACY_LUA_FILENAME = "jest.config.lua";
8072
- const SKIP_FIELDS = new Set(["exclude", "include"]);
8144
+ const SKIP_FIELDS = /* @__PURE__ */ new Set(["exclude", "include"]);
8073
8145
  function serializeToLuau(config) {
8074
8146
  let output = `${HEADER}return {\n`;
8075
8147
  for (const [key, value] of Object.entries(config)) {
@@ -8093,7 +8165,7 @@ function generateProjectConfigs(projects) {
8093
8165
  if (path.basename(project.outputPath) === "jest.config.luau") removeLegacyGeneratedStub(directory);
8094
8166
  }
8095
8167
  }
8096
- const STUB_SKIP_KEYS = new Set([
8168
+ const STUB_SKIP_KEYS = /* @__PURE__ */ new Set([
8097
8169
  "displayName",
8098
8170
  "exclude",
8099
8171
  "include",
@@ -8397,8 +8469,8 @@ function mergeResults(results) {
8397
8469
  }
8398
8470
  //#endregion
8399
8471
  //#region src/typecheck/collect.ts
8400
- const TEST_FUNCTIONS = new Set(["it", "test"]);
8401
- const ALL_FUNCTIONS = new Set([...new Set(["describe", "suite"]), ...TEST_FUNCTIONS]);
8472
+ const TEST_FUNCTIONS = /* @__PURE__ */ new Set(["it", "test"]);
8473
+ const ALL_FUNCTIONS = /* @__PURE__ */ new Set([.../* @__PURE__ */ new Set(["describe", "suite"]), ...TEST_FUNCTIONS]);
8402
8474
  function collectTestDefinitions(source) {
8403
8475
  const result = parseSync("test.ts", source);
8404
8476
  const raw = [];
@@ -9680,7 +9752,6 @@ function prepareForPackage(descriptor, workspaceRoot, ignore, timing) {
9680
9752
  shadowDir: shadowDirectory
9681
9753
  });
9682
9754
  }
9683
- if (useIncremental && previousManifest !== void 0) cleanupDeletedFiles(detectDeletedFiles(previousManifest, allFiles));
9684
9755
  const manifest = {
9685
9756
  buildId: crypto.randomUUID(),
9686
9757
  files: allFiles,
@@ -9689,7 +9760,7 @@ function prepareForPackage(descriptor, workspaceRoot, ignore, timing) {
9689
9760
  luauRoots: coverageRoots.map((entry) => entry.shadowDir),
9690
9761
  nonInstrumentedFiles: allNonInstrumented,
9691
9762
  shadowDir: normalizeWindowsPath(packageShadowRoot),
9692
- version: 3
9763
+ version: 4
9693
9764
  };
9694
9765
  atomicWrite(manifestPath, JSON.stringify(manifest, void 0, " "));
9695
9766
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/jest-roblox",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Jest-compatible CLI for running roblox-ts tests via Roblox Open Cloud",
5
5
  "keywords": [
6
6
  "jest",
@@ -48,7 +48,7 @@
48
48
  "!dist/**/*.tsbuildinfo"
49
49
  ],
50
50
  "dependencies": {
51
- "@bedrock-rbx/ocale": "0.1.0-beta.16",
51
+ "@bedrock-rbx/ocale": "0.1.0-beta.18",
52
52
  "@jridgewell/trace-mapping": "0.3.31",
53
53
  "arktype": "2.2.0",
54
54
  "c12": "4.0.0-beta.5",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@isentinel/eslint-config": "5.2.0",
70
- "@isentinel/roblox-ts": "4.0.6",
70
+ "@isentinel/roblox-ts": "4.0.7",
71
71
  "@isentinel/tsconfig": "1.2.0",
72
72
  "@isentinel/weld": "0.2.0",
73
73
  "@oxc-project/types": "0.123.0",
@@ -97,8 +97,8 @@
97
97
  "type-fest": "5.7.0",
98
98
  "typescript": "6.0.3",
99
99
  "vitest": "4.1.8",
100
- "@isentinel/roblox-runner": "0.1.0",
101
100
  "@isentinel/luau-ast": "0.1.0",
101
+ "@isentinel/roblox-runner": "0.1.0",
102
102
  "@isentinel/rojo-utils": "0.1.0"
103
103
  },
104
104
  "engines": {