@looop-games/cli 0.1.36 → 0.1.37

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/CHANGELOG.md CHANGED
@@ -14,6 +14,14 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.37] - 2026-09-06
18
+
19
+ - Windows: stopping a development or smoke stack terminates its worker descendants, releasing their ports and temporary folders.
20
+
21
+ - Windows: selective test runs recognize short and long names for the same project folder, and replay export accepts destinations inside the game folder.
22
+
23
+ - Test submissions with `looop test --since <base>`: ordinary checks plus explicitly triggered extended tests in `looop.tests.json`; `--list` explains selection and `--all` runs an explicit audit. Smoke stacks disable reload watchers, and module serving avoids repeated filesystem lookups within each dependency walk.
24
+
17
25
  ## [0.1.36] - 2026-09-04
18
26
 
19
27
  ### Added
package/bin/looop.mjs CHANGED
@@ -8,6 +8,7 @@ import { lane } from '../lib/lane.mjs';
8
8
  import { login, whoami } from '../lib/login.mjs';
9
9
  import { publish } from '../lib/publish.mjs';
10
10
  import { create } from '../lib/create.mjs';
11
+ import { parseTestArgs } from '../lib/test-policy.mjs';
11
12
  import { testCmd } from '../lib/test-cmd.mjs';
12
13
  import { lintCmd } from '../lib/lint.mjs';
13
14
  import { update } from '../lib/update.mjs';
@@ -30,7 +31,9 @@ Usage:
30
31
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
31
32
  looop assets [<id>] Browse and drive your models, sounds and assets on their own
32
33
  looop lane <name> Open an isolated copy of the game to experiment in, safely
33
- looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
34
+ looop test [aspect] Run targeted checks (no aspect: conservative full run)
35
+ looop test --since <base> Submission: ordinary + triggered extended checks
36
+ looop test --all Explicit full audit; add --list to preview selection
34
37
  looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
35
38
  looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
36
39
  looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
@@ -90,9 +93,7 @@ try {
90
93
  await create({ name: rest.find((a) => !a.startsWith('--')) });
91
94
  break;
92
95
  case 'test': {
93
- // Positional args scope the run: `looop test charge` runs only files whose
94
- // path contains "charge". Flags are left for future options (e.g. --changed).
95
- const { ok } = await testCmd({ patterns: rest.filter((a) => !a.startsWith('--')) });
96
+ const { ok } = await testCmd(parseTestArgs(rest));
96
97
  process.exit(ok ? 0 : 1);
97
98
  }
98
99
  case 'lint': {
package/lib/dev.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // Multiplayer runs from minute one — the platform is not optional (the Next
9
9
  // analogy deliberately breaks there). NO_MP=1 opts out for purely-SP work.
10
10
  import { spawn } from 'node:child_process';
11
+ import { terminateChildTree } from './process-tree.mjs';
11
12
  import { existsSync, readFileSync } from 'node:fs';
12
13
  import { tmpdir } from 'node:os';
13
14
  import { join, dirname, basename } from 'node:path';
@@ -50,7 +51,7 @@ export function partykitBin() {
50
51
  // and publish disagreed and a creator's primitive ran in production but not on
51
52
  // their own machine. One question, one answer, both commands.
52
53
 
53
- export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
54
+ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, log = console.log } = {}) {
54
55
  const project = findProject(cwd);
55
56
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
56
57
  // the platform's login-gated registry (no-op when the pinned version is in).
@@ -112,19 +113,8 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
112
113
  const ownServer = roomServerCwd !== engine.roomServerDir;
113
114
 
114
115
  const stop = () => {
115
- for (const c of children) {
116
- // partykit is npx-style: node wrapper forks workerd. Kill the tree.
117
- try {
118
- process.kill(-c.pid, 'SIGTERM');
119
- } catch {
120
- try {
121
- c.kill('SIGTERM');
122
- } catch {
123
- /* gone */
124
- }
125
- }
126
- }
127
116
  for (const s of servers) s.close();
117
+ for (const c of children) terminateChildTree(c);
128
118
  };
129
119
 
130
120
  // ─── static ───
@@ -159,14 +149,15 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
159
149
  { url: '/shared/', dir: overridesDir },
160
150
  { url: '/shared/', dir: engine.sharedDir },
161
151
  ],
162
- watchDirs: [project.dir, engine.sharedDir],
152
+ watchDirs: watch ? [project.dir, engine.sharedDir] : [],
153
+ injectReload: watch,
163
154
  // Present only for a framework-v2 game — injects the looop import map + the
164
155
  // lowered skeleton into the entry HTML so startGame() boots in the browser.
165
156
  skeleton: roomSkeleton,
166
157
  });
167
158
  await staticServer.listen(ports.static);
168
159
  servers.push(staticServer);
169
- log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload on)`);
160
+ log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload ${watch ? 'on' : 'off'})`);
170
161
 
171
162
  // ─── multiplayer (partykit on the bundle's room code) ───
172
163
  // A single-player framework-v2 game (no config({ multiplayer: true })) runs its
@@ -187,7 +178,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
187
178
  // partykit reloader below does this as a side effect; single-player needs a
188
179
  // stripped-down version with no child to respawn. (NO_MP on a real multiplayer
189
180
  // game has no client to serve a skeleton to, so it needs none.)
190
- if (singlePlayer) {
181
+ if (singlePlayer && watch) {
191
182
  const skelWatcher = createFileWatcher({
192
183
  files: roomInputs,
193
184
  dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
@@ -226,28 +217,27 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
226
217
  return pk;
227
218
  };
228
219
  const killMp = async (pk) => {
220
+ if (pk.pid == null) return;
221
+ // Keep ownership when termination fails so stop() can retry the tree.
222
+ // Child exit callbacks cannot run between these synchronous statements.
223
+ terminateChildTree(pk);
229
224
  const idx = children.indexOf(pk);
230
225
  if (idx !== -1) children.splice(idx, 1);
231
- if (pk.pid == null) return; // never started — nothing to reap, and 'exit' will never fire
232
226
  const exited = new Promise((resolveExit) => {
233
227
  if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
234
- pk.once('exit', resolveExit);
235
- pk.once('error', resolveExit);
228
+ const onExit = () => {
229
+ pk.removeListener('exit', onExit);
230
+ pk.removeListener('error', onExit);
231
+ resolveExit();
232
+ };
233
+ pk.once('exit', onExit);
234
+ pk.once('error', onExit);
236
235
  });
237
- try {
238
- process.kill(-pk.pid, 'SIGTERM');
239
- } catch {
240
- try {
241
- pk.kill('SIGTERM');
242
- } catch {
243
- /* gone */
244
- }
245
- }
246
236
  // partykit is a node wrapper forking workerd; if the tree ignores TERM,
247
237
  // escalate — the port must be free before the replacement binds it.
248
238
  const hardKill = setTimeout(() => {
249
239
  try {
250
- process.kill(-pk.pid, 'SIGKILL');
240
+ terminateChildTree(pk, 'SIGKILL');
251
241
  } catch {
252
242
  /* gone */
253
243
  }
@@ -315,18 +305,18 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
315
305
  },
316
306
  });
317
307
  reloader.attach(roomServerCwd, pk);
318
- roomWatcher = createFileWatcher({
308
+ roomWatcher = watch ? createFileWatcher({
319
309
  files: roomInputs,
320
310
  dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
321
311
  onChange: reloader.onChange,
322
- });
312
+ }) : null;
323
313
  // stop() (e.g. `looop test` tearing down its stack) must also stop the
324
314
  // reloader: a rebuild in flight would otherwise spawn a replacement room
325
315
  // child AFTER the children list was killed — a leaked detached process.
326
316
  servers.push({
327
317
  close: () => {
328
318
  reloader.stop();
329
- roomWatcher.close();
319
+ roomWatcher?.close();
330
320
  },
331
321
  });
332
322
  }
package/lib/inject.mjs CHANGED
@@ -137,7 +137,7 @@ function fileInfo(path) {
137
137
  return null;
138
138
  }
139
139
  const hit = parseCache.get(path);
140
- if (hit && hit.mtimeMs === st.mtimeMs) return hit;
140
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
141
141
  const specs = [];
142
142
  if (/\.(js|mjs)$/.test(path)) {
143
143
  let text = null;
@@ -152,35 +152,48 @@ function fileInfo(path) {
152
152
  }
153
153
  }
154
154
  }
155
- const info = { mtimeMs: st.mtimeMs, specs };
155
+ const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, specs };
156
156
  parseCache.set(path, info);
157
157
  return info;
158
158
  }
159
159
 
160
- // Walk in URL space: each node is a URL path, mapped to bytes through the
161
- // server's mount table. `memo` (shared across one rewrite pass) short-circuits
162
- // only entry nodes a spec repeated in one body — not intermediates.
163
- function closureStampUrl(urlPath, resolveUrl, memo) {
164
- if (memo?.has(urlPath)) return memo.get(urlPath);
160
+ // A rewrite shares resolved nodes across all import closures. Each URL is
161
+ // resolved/stat'ed once even when several roots reach it. The cache belongs to
162
+ // this synchronous rewrite only: the next request observes edits, deletions and
163
+ // newly-created overrides immediately, without waiting for the reload watcher.
164
+ function rewriteContext(resolveUrl) {
165
+ const nodes = new Map();
166
+ return {
167
+ stamps: new Map(),
168
+ node(url) {
169
+ if (!nodes.has(url)) {
170
+ const path = resolveUrl(url);
171
+ nodes.set(url, path ? fileInfo(path) : null);
172
+ }
173
+ return nodes.get(url);
174
+ },
175
+ };
176
+ }
177
+
178
+ function closureStampUrl(urlPath, context) {
179
+ if (context.stamps.has(urlPath)) return context.stamps.get(urlPath);
165
180
  let max = 0;
166
181
  const seen = new Set();
167
182
  const stack = [urlPath];
168
183
  while (stack.length) {
169
- const u = stack.pop();
170
- if (seen.has(u)) continue;
171
- seen.add(u);
172
- const fs = resolveUrl(u);
173
- if (!fs) continue;
174
- const info = fileInfo(fs);
184
+ const url = stack.pop();
185
+ if (seen.has(url)) continue;
186
+ seen.add(url);
187
+ const info = context.node(url);
175
188
  if (!info) continue;
176
- if (info.mtimeMs > max) max = info.mtimeMs;
189
+ max = Math.max(max, info.mtimeMs);
177
190
  for (const spec of info.specs) {
178
- const child = resolveSpecToUrl(spec, u);
191
+ const child = resolveSpecToUrl(spec, url);
179
192
  if (child) stack.push(child);
180
193
  }
181
194
  }
182
195
  const stamp = max ? Math.floor(max) : null;
183
- memo?.set(urlPath, stamp);
196
+ context.stamps.set(urlPath, stamp);
184
197
  return stamp;
185
198
  }
186
199
 
@@ -192,28 +205,28 @@ function closureStampUrl(urlPath, resolveUrl, memo) {
192
205
  // disk-space fallback. Resolving a relative spec beside the importer on disk
193
206
  // stamps the wrong file whenever a mount shadows it (the overrides mechanism),
194
207
  // which silently re-opens the staleness hole. No serving context → no stamp.
195
- function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, memo }) {
208
+ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context }) {
196
209
  if (!/\.(js|mjs|json)$/.test(relPath)) return null;
197
210
  if (baseUrl == null || !resolveUrl) return null;
198
211
  const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
199
212
  if (!urlTarget) return null;
200
- if (!resolveUrl(urlTarget)) return null; // nothing serves it — leave the spec alone
201
- const stamp = closureStampUrl(urlTarget, resolveUrl, memo);
213
+ if (!context.node(urlTarget)) return null;
214
+ const stamp = closureStampUrl(urlTarget, context);
202
215
  return stamp === null ? null : `${relPath}?v=${stamp}`;
203
216
  }
204
217
 
205
218
  export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
206
- const memo = new Map();
219
+ const context = rewriteContext(resolveUrl);
207
220
  return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
208
- const v = versionedPath(path, { baseUrl, resolveUrl, memo });
221
+ const v = versionedPath(path, { baseUrl, resolveUrl, context });
209
222
  return `${prefix}${quote}${v ?? path}${quote}`;
210
223
  });
211
224
  }
212
225
 
213
226
  export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
214
- const memo = new Map();
227
+ const context = rewriteContext(resolveUrl);
215
228
  return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
216
- const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, memo });
229
+ const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
217
230
  return `${prefix}${v ?? path}${suffix}`;
218
231
  });
219
232
  }
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ // PartyKit owns worker descendants. Windows has no Unix process groups, so
5
+ // killing its wrapper alone can leave a worker holding ports and directories.
6
+ export function terminateChildTree(child, signal = 'SIGTERM', {
7
+ platform = process.platform, exec = execFileSync, kill = process.kill,
8
+ } = {}) {
9
+ if (child.pid == null) return;
10
+ if (platform === 'win32') {
11
+ if (child.exitCode != null || child.signalCode != null) return;
12
+ const taskkill = win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
13
+ try {
14
+ exec(taskkill, ['/PID', String(child.pid), '/T', '/F'], {
15
+ stdio: 'pipe', timeout: 5000, windowsHide: true,
16
+ });
17
+ } catch (error) {
18
+ // An already-exited child is harmless; a live tree that could not be
19
+ // terminated must fail shutdown rather than silently leak workers.
20
+ try { kill(child.pid, 0); } catch (probe) {
21
+ if (probe.code === 'ESRCH') return;
22
+ }
23
+ throw error;
24
+ }
25
+ return;
26
+ }
27
+ try { kill(-child.pid, signal); } catch {
28
+ child.kill(signal);
29
+ }
30
+ }
@@ -4,7 +4,7 @@
4
4
  // hand both to the exporter, and then say enough about what came out that the
5
5
  // next step is obvious. The table's shape and every decision about it live in
6
6
  // replay-export.mjs; nothing here knows what a column is.
7
- import { join, relative, resolve } from 'node:path';
7
+ import { join, relative, resolve, sep } from 'node:path';
8
8
  import { statSync } from 'node:fs';
9
9
  import { findProject } from './project.mjs';
10
10
  import { streamDir } from './replay-store.mjs';
@@ -82,7 +82,7 @@ export async function replayExport({
82
82
  const outDir = out ? resolve(project.dir, out) : join(project.dir, ...EXPORTS_DIR);
83
83
  // The tables are tens of megabytes and this creates directories to hold them.
84
84
  // A path that climbs out of the game folder is a typo, not an intention.
85
- if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}/`)) {
85
+ if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}${sep}`)) {
86
86
  throw new Error(`--out must be inside the game folder, and ${out} is not`);
87
87
  }
88
88
  const engine = await ensureEngineImpl(project.dir, { log });
package/lib/test-cmd.mjs CHANGED
@@ -2,11 +2,12 @@
2
2
  //
3
3
  // Discovers the game's test files — `*.test.mjs` (unit, run under
4
4
  // `node --test`) and `*.smoke.mjs` (integration, run against a REAL dev
5
- // stack) — and runs them all. The files ARE the executable QA list: `/qa`
5
+ // stack) — and selects ordinary plus triggered extended checks. The files ARE the executable QA list: `/qa`
6
6
  // merges this gate with handbook/qa.md and the engine's master qa.md.
7
7
  //
8
8
  // The smoke stack boots on a free shifted port triple so it never seizes a
9
9
  // dev server the creator has running on :8000.
10
+ import { selectTests, readTestPolicy, changedTestPaths } from './test-policy.mjs';
10
11
  import { spawn } from 'node:child_process';
11
12
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
13
  import { createRequire } from 'node:module';
@@ -93,29 +94,36 @@ function looopTestPreload(projectDir) {
93
94
  }
94
95
  }
95
96
 
96
- export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [] } = {}) {
97
+ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [], since, all: runAll = false, list = false } = {}) {
97
98
  const project = findProject(cwd);
98
99
 
99
- // Freshness gate: referenced 3D models re-bake if their GLB or bake config
100
- // changed, BEFORE anything runs — a stale artifact means every hit test in
101
- // the suite exercises a surface the player no longer sees. A bake failure
102
- // fails the gate with the model named.
103
- const { ensureBaked } = await import('./model-bake.mjs');
104
- await ensureBaked({ dir: project.dir, log });
105
-
100
+ if ((since && runAll) || (patterns.length && (since || runAll))) throw new Error('Choose patterns, --since, or --all');
106
101
  const all = discoverTestFiles(project.dir);
107
-
108
- // Scoped run (`looop test <pattern>…`): keep only files whose path contains a
109
- // pattern. The file-naming convention already encodes the aspect
110
- // (`charge.smoke.mjs`, `riven-charge.test.mjs`), so a substring on the path
111
- // reaches both a unit test and its smoke without any source→test mapping.
112
- // This is a PER-STEP speed lever, not the gate — the full suite still runs at
113
- // milestone close and before publish (qa.md T3). With no pattern, everything
114
- // runs, exactly as before.
102
+ const files = [...all.unit, ...all.smokes];
103
+ const policy = readTestPolicy(project.dir, files);
115
104
  const scoped = patterns.length > 0;
116
- const matches = (f) => patterns.some((p) => posixRelative(project.dir, f).includes(p));
117
- const unit = scoped ? all.unit.filter(matches) : all.unit;
118
- const smokes = scoped ? all.smokes.filter(matches) : all.smokes;
105
+ let changed;
106
+ if (since) {
107
+ try { changed = changedTestPaths(project.dir, since); }
108
+ catch (error) { log(`⚠ Cannot compare with ${since}: ${error.message}. Including every extended test.`); }
109
+ }
110
+ const selection = scoped
111
+ ? { files: files.filter((f) => patterns.some((p) => posixRelative(project.dir, f).includes(p))), skipped: [], reason: 'targeted step checks' }
112
+ : selectTests({ dir: project.dir, files, policy, changed, all: runAll });
113
+ const selected = new Set(selection.files);
114
+ const unit = all.unit.filter((f) => selected.has(f));
115
+ const smokes = all.smokes.filter((f) => selected.has(f));
116
+ log(`▶ ${selection.reason}: ${selected.size} of ${files.length} test files`);
117
+ for (const entry of policy?.extended ?? []) {
118
+ const included = selection.files.some((f) => posixRelative(project.dir, f) === entry.test);
119
+ log(` ${included ? 'include' : 'defer'} extended ${entry.test} — ${entry.reason}`);
120
+ }
121
+ if (list) {
122
+ for (const file of selection.files) log(` ${posixRelative(project.dir, file)}`);
123
+ return { ok: !scoped || selected.size > 0, ran: 0, selection };
124
+ }
125
+ const { ensureBaked } = await import('./model-bake.mjs');
126
+ await ensureBaked({ dir: project.dir, log });
119
127
 
120
128
  // Lint FIRST — it needs no servers and no browser, and the defects it catches
121
129
  // (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
@@ -123,8 +131,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
123
131
  // Failing here does not skip the tests: the creator should see everything
124
132
  // that is wrong in one run, not peel it one gate at a time.
125
133
  //
126
- // A SCOPED run skips it: lint is the full gate's job and runs at milestone
127
- // close with the whole suite — a `looop test <pattern>` is a focused per-step
134
+ // A SCOPED run skips it: lint runs at the submission gate a `looop test <pattern>` is a focused per-step
128
135
  // re-run, not the gate.
129
136
  const lint = scoped ? { ok: true, skipped: true } : await lintFn({ cwd: project.dir, log });
130
137
 
@@ -145,7 +152,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
145
152
  }
146
153
 
147
154
  if (patterns.length) {
148
- log(`▶ scoped to ${patterns.map((p) => `"${p}"`).join(', ')}: ${unit.length + smokes.length} of ${all.unit.length + all.smokes.length} file(s) — full suite still runs at milestone close.`);
155
+ log(`▶ scoped to ${patterns.map((p) => `"${p}"`).join(', ')}: ${unit.length + smokes.length} of ${all.unit.length + all.smokes.length} file(s) — broader coverage runs at submission.`);
149
156
  }
150
157
 
151
158
  let ok = lint.ok;
@@ -190,11 +197,13 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
190
197
  }
191
198
  }
192
199
 
193
- const handle = await devFn({ cwd: project.dir, port: await freeTestPort(), log: () => {} });
200
+ const handle = await devFn({ cwd: project.dir, port: await freeTestPort(), watch: false, log: () => {} });
194
201
  log(`▶ smokes against ${handle.url}`);
195
202
  try {
196
203
  for (const file of smokes) {
197
204
  const rel = relative(project.dir, file);
205
+ const started = performance.now();
206
+ log(` ▶ ${rel}`);
198
207
  const code = await runFn(['--import', SMOKE_PRELOAD, ...looopImport, file], {
199
208
  cwd: project.dir,
200
209
  env: {
@@ -206,7 +215,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
206
215
  URL: handle.url,
207
216
  },
208
217
  });
209
- log(` ${code === 0 ? '✅' : '❌'} ${rel}`);
218
+ log(` ${code === 0 ? '✅' : '❌'} ${rel} (${((performance.now() - started) / 1000).toFixed(2)}s)`);
210
219
  if (code !== 0) ok = false;
211
220
  }
212
221
  } finally {
@@ -216,5 +225,5 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
216
225
 
217
226
  log('');
218
227
  log(ok ? `✅ looop test: all green (${unit.length} unit file(s), ${smokes.length} smoke(s))` : '❌ looop test: failures above');
219
- return { ok, ran: unit.length + smokes.length, lint };
228
+ return { ok, ran: unit.length + smokes.length, lint, selection };
220
229
  }
@@ -0,0 +1,100 @@
1
+ // Extended tests are opt-in declarations; every undeclared test stays ordinary.
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { join, relative, resolve } from 'node:path';
5
+
6
+ const POLICY = 'looop.tests.json';
7
+ const posix = (s) => s.replaceAll('\\', '/');
8
+
9
+ function matcher(pattern) {
10
+ if (typeof pattern !== 'string' || !pattern || pattern.startsWith('/') || pattern.includes('\\') || pattern.split('/').includes('..') || /[\[\]{}]/.test(pattern)) {
11
+ throw new Error(`${POLICY}: patterns must be relative paths using /, *, ** or ?`);
12
+ }
13
+ let source = '^';
14
+ for (let i = 0; i < pattern.length; i++) {
15
+ const c = pattern[i];
16
+ if (c === '*' && pattern[i + 1] === '*') {
17
+ i++;
18
+ if (pattern[i + 1] === '/') { i++; source += '(?:.*/)?'; }
19
+ else source += '.*';
20
+ } else if (c === '*') source += '[^/]*';
21
+ else if (c === '?') source += '[^/]';
22
+ else source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ }
24
+ return new RegExp(source + '$');
25
+ }
26
+
27
+ function patterns(value, label, { nonempty = false } = {}) {
28
+ if (!Array.isArray(value) || (nonempty && !value.length)) throw new Error(`${POLICY}: ${label} must be ${nonempty ? 'a nonempty' : 'an'} array`);
29
+ return value.map(matcher);
30
+ }
31
+ const matches = (path, rules) => rules.some((rule) => rule.test(path));
32
+
33
+ export function readTestPolicy(dir, files) {
34
+ const path = join(dir, POLICY);
35
+ if (!existsSync(path)) return null;
36
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
37
+ if (raw.version !== 1 || !Array.isArray(raw.extended)) throw new Error(`${POLICY}: expected version 1 and an extended array`);
38
+ const allowed = ['version', 'extended', 'ordinaryChanges', 'always'];
39
+ if (Object.keys(raw).some((k) => !allowed.includes(k))) throw new Error(`${POLICY}: unknown top-level field`);
40
+ const known = new Set(files.map((f) => posix(relative(dir, f))));
41
+ const seen = new Set();
42
+ const extended = raw.extended.map((entry) => {
43
+ if (!entry || typeof entry.test !== 'string' || !known.has(entry.test) || seen.has(entry.test)) throw new Error(`${POLICY}: extended test is missing or duplicated: ${entry?.test}`);
44
+ if (Object.keys(entry).some((k) => !['test', 'when', 'reason'].includes(k))) throw new Error(`${POLICY}: unknown field for ${entry.test}`);
45
+ if (typeof entry.reason !== 'string' || !entry.reason.trim()) throw new Error(`${POLICY}: ${entry.test} needs a reason`);
46
+ seen.add(entry.test);
47
+ return { ...entry, rules: patterns(entry.when, `${entry.test}.when`, { nonempty: true }) };
48
+ });
49
+ return {
50
+ extended,
51
+ ordinary: patterns(raw.ordinaryChanges ?? [], 'ordinaryChanges'),
52
+ always: patterns(raw.always ?? [], 'always'),
53
+ };
54
+ }
55
+
56
+ // Name-only without rename detection returns both sides of moves. NUL delimiters
57
+ // preserve spaces/newlines, and -- separates paths from command-line options.
58
+ export function changedTestPaths(dir, since) {
59
+ const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
60
+ if (!since || since.startsWith('-')) throw new Error('test --since needs an integration base, not an option');
61
+ const root = realpathSync.native(git(['rev-parse', '--show-toplevel']).trim());
62
+ const projectDir = realpathSync.native(dir);
63
+ const base = git(['merge-base', 'HEAD', since]).trim();
64
+ const changed = git(['-C', root, 'diff', '--no-relative', '--name-only', '--no-renames', '-z', base, '--']);
65
+ const untracked = git(['-C', root, 'ls-files', '--others', '--exclude-standard', '-z']);
66
+ return [...new Set((changed + untracked).split('\0').filter(Boolean).map((p) => posix(relative(projectDir, resolve(root, p)))))];
67
+ }
68
+
69
+ export function selectTests({ dir, files, policy, changed, all = false }) {
70
+ if (!policy || all) return { files, skipped: [], reason: all ? 'explicit --all audit' : 'all tests are ordinary (no policy)' };
71
+ if (!changed) return { files, skipped: [], reason: 'no integration base; including all extended tests (use --since <base> at submission)' };
72
+ const extended = new Map(policy.extended.map((entry) => [entry.test, entry]));
73
+ const testPaths = new Set(files.map((f) => posix(relative(dir, f))));
74
+ const broad = changed.find((p) => p === POLICY || matches(p, policy.always) || (!testPaths.has(p) && !matches(p, policy.ordinary) && !policy.extended.some((e) => matches(p, e.rules))));
75
+ if (broad) return { files, skipped: [], reason: `broad coverage: ${broad} is a foundation, policy, or unmapped change` };
76
+ const selected = files.filter((file) => {
77
+ const path = posix(relative(dir, file));
78
+ const entry = extended.get(path);
79
+ return !entry || changed.some((p) => p === path || matches(p, entry.rules));
80
+ });
81
+ const selectedSet = new Set(selected);
82
+ return { files: selected, skipped: files.filter((f) => !selectedSet.has(f)), reason: 'ordinary regressions plus extended tests triggered by changes' };
83
+ }
84
+
85
+ export function parseTestArgs(args) {
86
+ const options = { patterns: [], all: false, list: false };
87
+ for (let i = 0; i < args.length; i++) {
88
+ const arg = args[i];
89
+ if (arg === '--all') options.all = true;
90
+ else if (arg === '--list') options.list = true;
91
+ else if (arg === '--since') {
92
+ const value = args[++i];
93
+ if (!value || value.startsWith('-')) throw new Error('test --since requires a git integration base');
94
+ options.since = value;
95
+ } else if (arg.startsWith('-')) throw new Error(`Unknown test option: ${arg}`);
96
+ else options.patterns.push(arg);
97
+ }
98
+ if ((options.all && options.since) || (options.patterns.length && (options.all || options.since))) throw new Error('Choose test <pattern>, test --since <base>, or test --all');
99
+ return options;
100
+ }
@@ -0,0 +1,18 @@
1
+ // Adapter for runners with their own discovery rules. Stdout is NUL-delimited
2
+ // selected paths; explanations go to stderr so filenames remain lossless.
3
+ import { readTestPolicy, changedTestPaths, selectTests } from './test-policy.mjs';
4
+ const [dir, since, ...files] = process.argv.slice(2);
5
+ try {
6
+ const policy = readTestPolicy(dir, files);
7
+ let changed;
8
+ if (since !== '--all') {
9
+ try { changed = changedTestPaths(dir, since); }
10
+ catch (error) { console.error(`Cannot compare with ${since}: ${error.message}. Including all tests.`); }
11
+ }
12
+ const selection = selectTests({ dir, files, policy, changed, all: since === '--all' });
13
+ console.error(`Test selection: ${selection.reason}; ${selection.files.length} selected, ${selection.skipped.length} deferred.`);
14
+ process.stdout.write(selection.files.map((file) => file + '\0').join(''));
15
+ } catch (error) {
16
+ console.error(error.message);
17
+ process.exitCode = 1;
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,7 +17,7 @@
17
17
  "node": ">=20.11"
18
18
  },
19
19
  "scripts": {
20
- "test": "node --test 'lib/*.test.mjs'"
20
+ "test": "node scripts/run-tests.mjs"
21
21
  },
22
22
  "dependencies": {
23
23
  "cross-spawn": "^7.0.6",