@looop-games/cli 0.1.11 → 0.1.12

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,21 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.12] - 2026-07-12
18
+
19
+ ### Added
20
+ - `looop lint` — check your game against the Looop rules: the mistakes that ship
21
+ a broken game while every test stays green. `looop lint --fix` applies the ones
22
+ that can be fixed mechanically.
23
+ - `looop test` now runs the lint first, before it starts a browser. A hardcoded
24
+ multiplayer host is reported in a second, instead of after a full smoke run
25
+ that passed for the wrong reason.
26
+
27
+ The rules themselves live in the engine (so `looop update` brings you new ones)
28
+ and your game's `eslint.config.js` imports the same file the gate runs — the
29
+ editor and the test can't disagree. Nothing changes for an existing game until
30
+ you install eslint (`npm i -D eslint`); new games get it out of the box.
31
+
17
32
  ## [0.1.11] - 2026-07-12
18
33
 
19
34
  ### Fixed
package/bin/looop.mjs CHANGED
@@ -8,6 +8,7 @@ import { login, whoami } from '../lib/login.mjs';
8
8
  import { publish } from '../lib/publish.mjs';
9
9
  import { create } from '../lib/create.mjs';
10
10
  import { testCmd } from '../lib/test-cmd.mjs';
11
+ import { lintCmd } from '../lib/lint.mjs';
11
12
  import { update } from '../lib/update.mjs';
12
13
  import { changelog } from '../lib/changelog.mjs';
13
14
  import { sendFeedback } from '../lib/feedback.mjs';
@@ -26,6 +27,7 @@ Usage:
26
27
  looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
27
28
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
28
29
  looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
30
+ looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
29
31
  looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
30
32
  looop update Move this game to the latest engine release (re-pins looop.engine)
31
33
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
@@ -59,6 +61,10 @@ try {
59
61
  const { ok } = await testCmd();
60
62
  process.exit(ok ? 0 : 1);
61
63
  }
64
+ case 'lint': {
65
+ const { ok } = await lintCmd({ fix: rest.includes('--fix') });
66
+ process.exit(ok ? 0 : 1);
67
+ }
62
68
  case 'changelog':
63
69
  await changelog({
64
70
  version: rest.find((a) => !a.startsWith('--')),
package/lib/create.mjs CHANGED
@@ -67,6 +67,14 @@ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
67
67
  // what create-time provisioning exists to prevent.
68
68
  const PLAYWRIGHT_SPEC = '^1.61.1';
69
69
 
70
+ // The Looop rules ship as an ESLint config (the scaffold's eslint.config.js,
71
+ // importing the rules from the installed engine), so they squiggle in the
72
+ // creator's editor as the code is written — a far tighter loop than a failing
73
+ // gate, and the whole reason this is ESLint and not a bespoke scanner. That
74
+ // only works if eslint is actually installed, so it is a scaffold
75
+ // devDependency, exactly like playwright. `looop test` also runs it.
76
+ const ESLINT_SPEC = '^10.7.0';
77
+
70
78
  // Copy the starter game out of the artifact. One rename: `gitignore` →
71
79
  // `.gitignore` (npm pack silently strips .gitignore files from a package, so
72
80
  // the artifact stores it dot-less).
@@ -105,8 +113,8 @@ async function scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, rec
105
113
  name,
106
114
  private: true,
107
115
  description: `A Looop game. Play at https://play.looop.games/g/${name}`,
108
- scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test' },
109
- devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC },
116
+ scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test', lint: 'looop lint' },
117
+ devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC, eslint: ESLINT_SPEC },
110
118
  allowScripts: ALLOW_SCRIPTS,
111
119
  },
112
120
  null,
package/lib/lint.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // `looop lint` — the hard rules, made executable.
2
+ //
3
+ // A "Never" that lives only in prose is a suggestion. The rules that have
4
+ // actually shipped broken games are enforced here: in the editor as you type
5
+ // (via the game's eslint.config.js) and in the gate (`looop test` runs this
6
+ // first).
7
+ //
8
+ // The rules themselves live in the ENGINE (`<engine>/eslint/`), not in this
9
+ // package — they are something the creator receives, so they have one authoring
10
+ // home and one release lane, and `looop update` brings a game the new ones. This
11
+ // command resolves them out of the game's installed engine, which is the very
12
+ // same file its eslint.config.js imports. One copy: the gate and the editor
13
+ // cannot disagree.
14
+ //
15
+ // Escape hatch: `// eslint-disable-next-line looop/<rule>` — the one every
16
+ // developer already knows. This is mandatory, not a nicety: creators are not
17
+ // developers, and a false positive must never wall someone out of their own
18
+ // test command with no way through.
19
+ import { existsSync } from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+ import { join, relative } from 'node:path';
22
+ import { pathToFileURL } from 'node:url';
23
+ import { findProject, resolveEngine } from './project.mjs';
24
+
25
+ const CONFIG_NAMES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'];
26
+
27
+ // Resolve ESLint from the GAME's node_modules — same preflight shape as
28
+ // playwright in test-cmd.mjs. Games scaffolded before lint existed simply don't
29
+ // have it; they skip rather than break.
30
+ async function loadFromProject(projectDir) {
31
+ try {
32
+ const req = createRequire(join(projectDir, 'package.json'));
33
+ const mod = await import(pathToFileURL(req.resolve('eslint')).href);
34
+ return mod.ESLint ?? mod.default?.ESLint ?? null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // The shareable config, out of the installed engine artifact.
41
+ async function loadRulesFromEngine(projectDir) {
42
+ let engineDir;
43
+ try {
44
+ ({ dir: engineDir } = resolveEngine(projectDir));
45
+ } catch {
46
+ return null; // no engine installed yet — `looop dev` installs it
47
+ }
48
+ const entry = join(engineDir, 'eslint', 'index.js');
49
+ if (!existsSync(entry)) return null; // engine predates the rules
50
+ const mod = await import(pathToFileURL(entry).href);
51
+ return mod.configs ?? mod.default?.configs ?? null;
52
+ }
53
+
54
+ export async function lintCmd({
55
+ cwd = process.cwd(),
56
+ fix = false,
57
+ log = console.log,
58
+ loadEslint = loadFromProject,
59
+ loadRules = loadRulesFromEngine,
60
+ } = {}) {
61
+ const project = findProject(cwd);
62
+
63
+ const ESLint = await loadEslint(project.dir);
64
+ if (!ESLint) {
65
+ log('⚠️ looop lint: eslint is not installed in this game — skipping the rule checks.');
66
+ log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
67
+ log(' npm i -D eslint');
68
+ return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
69
+ }
70
+
71
+ const configs = await loadRules(project.dir);
72
+ if (!configs) {
73
+ log('⚠️ looop lint: this engine ships no rules yet — skipping.');
74
+ log(' Get the latest engine (it carries them): looop update');
75
+ return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
76
+ }
77
+
78
+ // A game that ships its own eslint.config.js owns its rules — we do not
79
+ // override it (and the scaffold's config already spreads ours in). Otherwise
80
+ // lint with the engine's recommended set, so a repo created before lint
81
+ // existed still gets the checks with zero setup.
82
+ const hasOwnConfig = CONFIG_NAMES.some((n) => existsSync(join(project.dir, n)));
83
+ const eslint = new ESLint({
84
+ cwd: project.dir,
85
+ fix,
86
+ ...(hasOwnConfig ? {} : { overrideConfigFile: true, baseConfig: configs.recommended }),
87
+ });
88
+
89
+ const results = await eslint.lintFiles(['.']);
90
+ if (fix) await ESLint.outputFixes(results);
91
+
92
+ let errorCount = 0;
93
+ let warningCount = 0;
94
+ for (const r of results) {
95
+ errorCount += r.errorCount;
96
+ warningCount += r.warningCount;
97
+ }
98
+
99
+ if (errorCount || warningCount) {
100
+ // ESLint's own stylish formatter: the creator's agent (and their editor)
101
+ // already know how to read it.
102
+ const formatter = await eslint.loadFormatter('stylish');
103
+ const text = await formatter.format(results);
104
+ if (text.trim()) log(text.trim());
105
+ }
106
+
107
+ if (errorCount === 0) {
108
+ log(`✅ looop lint: clean (${results.length} file(s))${fix ? ' — fixes applied' : ''}`);
109
+ } else {
110
+ log('');
111
+ log(`❌ looop lint: ${errorCount} error(s) in ${results.filter((r) => r.errorCount).length} file(s)`);
112
+ if (results.some((r) => r.messages.some((m) => m.fix))) {
113
+ log(' Some of these are auto-fixable: `looop lint --fix`');
114
+ }
115
+ log(' A rule is wrong about your code? `// eslint-disable-next-line <rule>` above the line.');
116
+ }
117
+
118
+ return {
119
+ ok: errorCount === 0,
120
+ skipped: false,
121
+ errorCount,
122
+ warningCount,
123
+ files: results.map((r) => relative(project.dir, r.filePath)),
124
+ };
125
+ }
package/lib/test-cmd.mjs CHANGED
@@ -13,6 +13,7 @@ import { createRequire } from 'node:module';
13
13
  import { join, relative, dirname } from 'node:path';
14
14
  import { findProject } from './project.mjs';
15
15
  import { dev } from './dev.mjs';
16
+ import { lintCmd } from './lint.mjs';
16
17
  import { portsFor, portInUse } from './ports.mjs';
17
18
 
18
19
  const SKIP_DIRS = new Set(['node_modules', 'overrides']);
@@ -56,17 +57,24 @@ async function freeTestPort(base = 8100) {
56
57
  throw new Error('no free port triple found for the test dev stack');
57
58
  }
58
59
 
59
- export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev } = {}) {
60
+ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd } = {}) {
60
61
  const project = findProject(cwd);
61
62
  const { unit, smokes } = discoverTestFiles(project.dir);
62
63
 
64
+ // Lint FIRST — it needs no servers and no browser, and the defects it catches
65
+ // (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
66
+ // exactly the ones that make the suite below pass while testing nothing.
67
+ // Failing here does not skip the tests: the creator should see everything
68
+ // that is wrong in one run, not peel it one gate at a time.
69
+ const lint = await lintFn({ cwd: project.dir, log });
70
+
63
71
  if (unit.length + smokes.length === 0) {
64
72
  log(`No tests yet in ${project.dir} — nothing to run.`);
65
73
  log('(Unit tests are *.test.mjs; browser smokes are *.smoke.mjs. Anything with an assertion is a regression test — keep it.)');
66
- return { ok: true, ran: 0 };
74
+ return { ok: lint.ok, ran: 0 };
67
75
  }
68
76
 
69
- let ok = true;
77
+ let ok = lint.ok;
70
78
 
71
79
  // If looop test itself runs under a node --test parent, the inherited
72
80
  // NODE_TEST_CONTEXT makes a nested `node --test` exit 0 even on failure —
@@ -130,5 +138,5 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
130
138
 
131
139
  log('');
132
140
  log(ok ? `✅ looop test: all green (${unit.length} unit file(s), ${smokes.length} smoke(s))` : '❌ looop test: failures above');
133
- return { ok, ran: unit.length + smokes.length };
141
+ return { ok, ran: unit.length + smokes.length, lint };
134
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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",