@moku-labs/game 0.8.0 → 0.10.0

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.
Files changed (60) hide show
  1. package/README.md +88 -26
  2. package/bin/moku-game.mjs +4 -0
  3. package/dist/app/page.d.mts +41 -0
  4. package/dist/app/page.mjs +376 -0
  5. package/dist/app/system.d.mts +129 -0
  6. package/dist/app/system.mjs +307 -0
  7. package/dist/app.d.mts +37 -0
  8. package/dist/app.mjs +235 -0
  9. package/dist/assets-BHwTLVta.mjs +7391 -0
  10. package/dist/assets.d.mts +97 -33
  11. package/dist/assets.mjs +1 -7150
  12. package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
  13. package/dist/cli.d.mts +70 -0
  14. package/dist/cli.mjs +1650 -0
  15. package/dist/{commands-C1kIfOG5.d.mts → commands-C4QlqijJ.d.mts} +5 -6
  16. package/dist/config-CdARrrhU.mjs +81 -0
  17. package/dist/control.d.mts +11 -10
  18. package/dist/control.mjs +3 -3
  19. package/dist/{define-BPpZcpxW.d.mts → define-CCc0ozKe.d.mts} +2 -2
  20. package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
  21. package/dist/{memory-CvgdnsQO.mjs → drafts-BawpSaVp.mjs} +1 -104
  22. package/dist/fake-MpzwZhIZ.mjs +87 -0
  23. package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
  24. package/dist/{headless-q98SwADR.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
  25. package/dist/hot.d.mts +8 -8
  26. package/dist/hot.mjs +15 -14
  27. package/dist/index-xxSxKJJW.d.mts +1611 -0
  28. package/dist/index.d.mts +6 -1597
  29. package/dist/index.mjs +4 -34343
  30. package/dist/inspect-la_KkjCS.mjs +1 -0
  31. package/dist/inspect.d.mts +7 -8
  32. package/dist/inspect.mjs +3 -2
  33. package/dist/jsx-dev-runtime.d.mts +1 -1
  34. package/dist/jsx-runtime.d.mts +1 -1
  35. package/dist/lint.d.mts +55 -4
  36. package/dist/lint.mjs +999 -42
  37. package/dist/memory-CWlqhS7l.mjs +136 -0
  38. package/dist/memory-DwDhfFWK.d.mts +50 -0
  39. package/dist/project.d.mts +29 -13
  40. package/dist/project.mjs +1120 -631
  41. package/dist/{run-CUPSFM85.mjs → run-wYty_Lwo.mjs} +4 -4
  42. package/dist/{runtime-CvDZZg1S.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
  43. package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
  44. package/dist/{sources-BBUd15vQ.mjs → sources-Duzj6lhV.mjs} +3 -3
  45. package/dist/src-DSl1eNB1.mjs +34366 -0
  46. package/dist/testing.d.mts +115 -34
  47. package/dist/testing.mjs +175 -70
  48. package/dist/{types-DlgsfOmW.d.mts → types-BM4shh8K.d.mts} +300 -263
  49. package/dist/{types-DgdMLZnV.d.mts → types-BUwDHKXm.d.mts} +1372 -1373
  50. package/dist/{types-DF5kkW-C.d.mts → types-CefgYzFt.d.mts} +4 -5
  51. package/dist/{types-CDSKa0vl.d.mts → types-DAdwEJMp.d.mts} +2 -2
  52. package/dist/{types-BWzCSRM2.d.mts → types-ZQLYXo5X.d.mts} +2983 -20
  53. package/dist/types-hQgI_wrq.d.mts +544 -0
  54. package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
  55. package/dist/visual.d.mts +10 -11
  56. package/dist/visual.mjs +5 -5
  57. package/llms.txt +170 -57
  58. package/package.json +30 -2
  59. package/dist/registry-DlpRCibU.mjs +0 -384
  60. package/dist/types-ERb2Y9U5.d.mts +0 -2967
package/dist/lint.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync, statSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  //#region src/lint.ts
3
4
  /**
@@ -6,7 +7,9 @@ import path from "node:path";
6
7
  * (`"jsPlugins": ["@moku-labs/game/lint"]`) and turns the rules on as `moku-game/<rule>`. The rules
7
8
  * use the ESLint rule API, so ESLint 9 loads the same object. Every rule takes
8
9
  * `[{ files, ignores }]`: globs relative to the directory oxlint runs in; a key given replaces the
9
- * default. Only `node:path` is imported; the file is erasable TypeScript, so Node loads it unbuilt.
10
+ * default. The layout rules also take the folder of the game's layers (`root`) and the tsconfig
11
+ * whose `paths` name the aliases (`tsconfig`). Only `node:path` and `node:fs` are imported; the
12
+ * file is erasable TypeScript, so Node loads it unbuilt.
10
13
  */
11
14
  /** Tests and test helpers: no rule checks them by default. */
12
15
  const TESTS = [
@@ -16,23 +19,58 @@ const TESTS = [
16
19
  ];
17
20
  /** Dev modules: a `.dev` module is wired into the editor only. */
18
21
  const DEV_MODULES = ["**/*.dev.{ts,tsx}"];
19
- /** The logic of a game: state, tables, nodes, flows, rules, features. `**` so `src/` matches too. */
22
+ /**
23
+ * The logic of a game: state, tables, the game in the root index.ts, the root flow in game.ts,
24
+ * core, nodes, flows, rules, features, shared. `**` so `src/` matches too; `index.ts` is the root
25
+ * file only.
26
+ */
20
27
  const LOGIC = [
28
+ "index.ts",
21
29
  "**/state.ts",
22
30
  "**/tables.ts",
23
- "**/{nodes,flows,rules,features}/**"
31
+ "**/game.ts",
32
+ "**/{core,nodes,flows,rules,features,shared}/**"
24
33
  ];
25
- /** The options schema every rule shares; oxlint refuses options a rule has no schema for. */
34
+ /** The effect side: moku plugins answer effects and may hold timers. */
35
+ const EFFECT_SIDE = ["**/{plugin,plugins}/**"];
36
+ /** The folders `test-suffix` reads. */
37
+ const TEST_FOLDERS = ["**/tests/**", "**/__tests__/**"];
38
+ /** The scope options every rule shares. */
39
+ const SCOPE_PROPERTIES = {
40
+ files: {
41
+ type: "array",
42
+ items: { type: "string" }
43
+ },
44
+ ignores: {
45
+ type: "array",
46
+ items: { type: "string" }
47
+ }
48
+ };
49
+ /** The options schema of the rules without a layout; oxlint refuses options a rule has no schema for. */
26
50
  const SCHEMA = [{
51
+ type: "object",
52
+ properties: SCOPE_PROPERTIES,
53
+ additionalProperties: false
54
+ }];
55
+ /** The options schema of the layout rules: the scope, `root` and `tsconfig`. */
56
+ const LAYOUT_SCHEMA = [{
27
57
  type: "object",
28
58
  properties: {
29
- files: {
30
- type: "array",
31
- items: { type: "string" }
32
- },
33
- ignores: {
34
- type: "array",
35
- items: { type: "string" }
59
+ ...SCOPE_PROPERTIES,
60
+ root: { type: "string" },
61
+ tsconfig: { type: "string" }
62
+ },
63
+ additionalProperties: false
64
+ }];
65
+ /** The options schema of `test-suffix`: the scope, `root` and `suffixes`. */
66
+ const SUFFIX_SCHEMA = [{
67
+ type: "object",
68
+ properties: {
69
+ ...SCOPE_PROPERTIES,
70
+ root: { type: "string" },
71
+ suffixes: {
72
+ type: "object",
73
+ additionalProperties: { type: "string" }
36
74
  }
37
75
  },
38
76
  additionalProperties: false
@@ -74,6 +112,101 @@ const KEY_HOPS = 3;
74
112
  /** What static-keys says about a key the project index cannot follow. */
75
113
  const STATIC_KEYS_MESSAGE = "Keys: a JSX key must be a literal, a template, or props.id / props.<name>Key. Pass the key in as a prop: <X id=\"…\">.";
76
114
  /**
115
+ * The aliases of the v15 layout, relative to the game root. The layout rules read them when the
116
+ * tsconfig is missing, declares no `paths` or does not parse; a `paths` block replaces them whole.
117
+ */
118
+ const V15_PATHS = {
119
+ "@core/*": ["core/*"],
120
+ "@shared": ["shared/index.ts"],
121
+ "@shared/rules": ["shared/rules/index.ts"],
122
+ "@features": ["features/index.ts"],
123
+ "@features/*": ["features/*/index.ts"],
124
+ "@plugins": ["plugins/index.ts"],
125
+ "@generated/*": ["generated/*"],
126
+ "@tests/*": ["tests/*"]
127
+ };
128
+ /** The engine and its subpaths: a package even when the tsconfig `paths` map it. */
129
+ const ENGINE = /^@moku-labs\/game(?:\/|$)/;
130
+ /** A specifier relative to the importing file, or an absolute path: never an alias. */
131
+ const NOT_BARE = /^(?:\.{1,2}(?:\/|$)|\/)/;
132
+ /** A specifier relative to the importing file. */
133
+ const RELATIVE = /^\.{1,2}(?:\/|$)/;
134
+ /** How many configs deep an `extends` chain is followed; a cycle ends there too. */
135
+ const MAX_EXTENDS = 8;
136
+ /** The stamp of a config file that is not there. */
137
+ const MISSING = -1;
138
+ /** A door file: the `index` of a unit, with or without its extension. */
139
+ const INDEX_FILE = /^index(?:\.[jt]sx?)?$/;
140
+ /** The root flow of a game, with or without its extension. */
141
+ const GAME_FILE = /^game(?:\.[jt]sx?)?$/;
142
+ /** The place of every path outside the layout. */
143
+ const OTHER = {
144
+ layer: "other",
145
+ door: false
146
+ };
147
+ /** The layers each layer imports. `game.ts` imports every layer; the rest is not checked. */
148
+ const LAYER_REACH = {
149
+ core: ["core", "generated"],
150
+ shared: [
151
+ "core",
152
+ "shared",
153
+ "generated"
154
+ ],
155
+ features: [
156
+ "core",
157
+ "shared",
158
+ "features",
159
+ "generated"
160
+ ],
161
+ plugins: [
162
+ "core",
163
+ "shared",
164
+ "plugins",
165
+ "generated"
166
+ ]
167
+ };
168
+ /** The second sentence of every layer-imports report. */
169
+ const LAYER_ORDER = "Order: core ← shared ← features ← game.ts; plugins import core and shared.";
170
+ /** What feature-door says about a barrel imported below game.ts. */
171
+ const BARREL_MESSAGE = "Door: only game.ts imports the @features and @plugins barrels.";
172
+ /** The specifiers a rule imports besides its siblings, as written. */
173
+ const RULE_ALIASES = /^@(?:core\/types|shared\/rules)$/;
174
+ /** The files a rule imports besides its siblings, relative to the game root, any spelling. */
175
+ const RULE_IMPORTS = [/^core\/types(?:\.[jt]sx?|\/.+)?$/, /^shared\/rules(?:\/index(?:\.[jt]sx?)?)?$/];
176
+ /** What rules-siblings says. */
177
+ const SIBLINGS_MESSAGE = "L4: a rule imports only its siblings in rules/, @core/types and @shared/rules: (state, input, tables) => result.";
178
+ /** The suffix of each test kind folder. A table given in the options replaces it whole. */
179
+ const TEST_SUFFIXES = {
180
+ "tests/e2e/": ".e2e.ts",
181
+ "tests/visual/": ".visual.ts",
182
+ "tests/editor/": ".editor.ts",
183
+ "__tests__/": ".test.ts",
184
+ "__tests__/unit/": ".test.ts",
185
+ "__tests__/integration/": ".test.ts",
186
+ "__tests__/isolated/": ".isolated.ts",
187
+ "__tests__/visual/": ".visual.ts"
188
+ };
189
+ /** A test root folder. */
190
+ const TEST_ROOT = /^(?:tests|__tests__)$/;
191
+ /** A kind folder key that names a test root itself: it holds only the files directly inside. */
192
+ const TEST_ROOT_KEY = /(?:^|\/)(?:tests|__tests__)\/$/;
193
+ /** The sub-folders of a test root whose files are helpers, not tests. */
194
+ const HELPER_FOLDER = /^(?:helpers|fixtures|baselines)$/;
195
+ /** A source file `test-suffix` reads. */
196
+ const SCRIPT = /\.tsx?$/;
197
+ /** The extension of a JSX test file: a `.ts` suffix of its kind takes an `x` for it. */
198
+ const TSX_EXTENSION = ".tsx";
199
+ /** The extension a suffix of a TypeScript kind ends with. */
200
+ const TS_EXTENSION = ".ts";
201
+ /** The folder of a colocated test root. */
202
+ const COLOCATED_TESTS = "__tests__";
203
+ /** Where a helper of a colocated `__tests__/` kind goes. */
204
+ const COLOCATED_HELPERS = "__tests__/fixtures/";
205
+ /** Where a helper of a root `tests/` kind goes. */
206
+ const ROOT_HELPERS = "tests/helpers/";
207
+ /** The test word a misnamed file may carry already: `board.test.ts` becomes `board.e2e.ts`. */
208
+ const TEST_WORD = /\.(?:test|spec|e2e|visual|editor|isolated)$/;
209
+ /**
77
210
  * Turn a glob into an anchored regular expression: `**`, `*`, `?` and one level of `{a,b}`.
78
211
  *
79
212
  * @param glob - A POSIX glob relative to the lint root.
@@ -118,6 +251,34 @@ function globChar(char) {
118
251
  }
119
252
  }
120
253
  /**
254
+ * A path with `/` between its parts on every platform.
255
+ *
256
+ * @param file - A path.
257
+ * @returns The POSIX spelling.
258
+ * @example
259
+ * ```ts
260
+ * toPosix("core/kit.ts"); // "core/kit.ts"
261
+ * ```
262
+ */
263
+ function toPosix(file) {
264
+ return file.split(path.sep).join("/");
265
+ }
266
+ /**
267
+ * A path relative to a folder, when it lies inside it.
268
+ *
269
+ * @param folder - The absolute folder.
270
+ * @param file - The absolute path.
271
+ * @returns The relative POSIX path, or undefined for a path outside the folder.
272
+ * @example
273
+ * ```ts
274
+ * insideOf("/game", "/game/core/kit.ts"); // "core/kit.ts"
275
+ * ```
276
+ */
277
+ function insideOf(folder, file) {
278
+ const relative = toPosix(path.relative(folder, file));
279
+ return relative === ".." || relative.startsWith("../") || path.isAbsolute(relative) ? void 0 : relative;
280
+ }
281
+ /**
121
282
  * Whether the rule checks the file the context lints: `files` match and no `ignores` match.
122
283
  *
123
284
  * @param context - The rule context.
@@ -128,7 +289,7 @@ function inScope(context, defaults) {
128
289
  const [given] = context.options;
129
290
  const files = given?.files ?? defaults.files;
130
291
  const ignores = given?.ignores ?? defaults.ignores;
131
- const file = path.relative(context.cwd, context.filename).split(path.sep).join("/");
292
+ const file = toPosix(path.relative(context.cwd, context.filename));
132
293
  const matches = (globs) => globs.some((glob) => globToRegExp(glob).test(file));
133
294
  return matches(files) && !matches(ignores);
134
295
  }
@@ -266,22 +427,24 @@ const lazyImports = importRule("No static import of pixi.js or yoga-layout; impo
266
427
  pattern: /^yoga-layout(?:\/|$)/,
267
428
  message: "L2: the ui plugin loads Yoga lazily. Use import() or import type."
268
429
  }], true);
269
- /** L13: native packages stay out of the engine-facing layers. */
270
- const nativeImports = importRule("No @moku-labs/system, @moku-labs/native or @tauri-apps/* in the engine-facing layers.", {
430
+ /** L13: a game imports no native package; config.ts names the capability, the engine page wires it. */
431
+ const nativeImports = importRule("No @moku-labs/system, @moku-labs/native or @tauri-apps/* in a game: the engine page wires them.", {
271
432
  files: [
272
433
  ...LOGIC,
434
+ "config.ts",
273
435
  "**/kit.ts",
274
- "**/game.ts"
436
+ "**/plugins/**"
275
437
  ],
276
438
  ignores: [...DEV_MODULES, ...TESTS]
277
439
  }, [{
278
440
  pattern: /^(?:@moku-labs\/(?:system|native)(?:\/|$)|@tauri-apps\/)/,
279
- message: "L13: only platform-bridge.ts, native.ts and web/ import a native package. Pass a PlatformProvider."
441
+ message: "L13: a game imports no native package. Name the capability in config.ts system; the engine page wires it."
280
442
  }]);
281
443
  /** Dev only: the editor and the control door stay in dev files. */
282
444
  const developmentImports = importRule("@moku-labs/editor and @moku-labs/game/control only in dev files and tests.", {
283
445
  files: ["**"],
284
446
  ignores: [
447
+ ".moku/**",
285
448
  "web/main.ts",
286
449
  "web/dev*.ts",
287
450
  "web/editor*.ts",
@@ -290,15 +453,7 @@ const developmentImports = importRule("@moku-labs/editor and @moku-labs/game/con
290
453
  ]
291
454
  }, [{
292
455
  pattern: /^@moku-labs\/(?:editor(?:\/|$)|game\/control$)/,
293
- message: "Dev only: web/main.ts, web/dev*.ts, web/editor*.ts, .dev modules and tests import the editor and /control."
294
- }]);
295
- /** L4: a rule imports only its siblings. */
296
- const rulesSiblings = importRule("A file under rules/ imports only its siblings.", {
297
- files: ["**/rules/**"],
298
- ignores: TESTS
299
- }, [{
300
- pattern: /^(?!\.\/)/,
301
- message: "L4: a rule imports only its siblings in rules/: (state, input, tables) => result."
456
+ message: "Dev only: the dev page (web/main.ts, web/dev*.ts, web/editor*.ts), .dev modules and tests import the editor and /control."
302
457
  }]);
303
458
  /** L5: no module-scope `let` or `var` and no module-scope collection. */
304
459
  const noModuleState = {
@@ -323,7 +478,7 @@ const noModuleState = {
323
478
  } };
324
479
  }
325
480
  };
326
- /** L3: no device clock, no timers, no unseeded randomness in the logic. */
481
+ /** L3: no device clock, no timers, no unseeded randomness in the logic. Plugins hold the timers. */
327
482
  const determinism = {
328
483
  meta: {
329
484
  type: "problem",
@@ -337,9 +492,14 @@ const determinism = {
337
492
  * @returns The node visitors, none for a file out of scope.
338
493
  */
339
494
  create(context) {
495
+ const ignores = [
496
+ ...EFFECT_SIDE,
497
+ ...DEV_MODULES,
498
+ ...TESTS
499
+ ];
340
500
  if (!inScope(context, {
341
501
  files: LOGIC,
342
- ignores: [...DEV_MODULES, ...TESTS]
502
+ ignores
343
503
  })) return {};
344
504
  return {
345
505
  MemberExpression: (node) => {
@@ -439,6 +599,799 @@ function isStaticKey(context, node, hops) {
439
599
  default: return false;
440
600
  }
441
601
  }
602
+ /** Keys: a JSX key is a shape the project index reads, so `find` reaches it from a runtime key. */
603
+ const staticKeys = {
604
+ meta: {
605
+ type: "problem",
606
+ docs: { description: "A JSX key is a literal, a template, props.id or props.<name>Key, or a call." },
607
+ schema: SCHEMA
608
+ },
609
+ /**
610
+ * Visit the file when the rule's globs match it.
611
+ *
612
+ * @param context - The rule context.
613
+ * @returns The node visitors, none for a file out of scope.
614
+ */
615
+ create(context) {
616
+ if (!inScope(context, {
617
+ files: ["**/*.tsx"],
618
+ ignores: TESTS
619
+ })) return {};
620
+ return { JSXAttribute: (node) => {
621
+ const container = isNode(node.value) ? node.value : void 0;
622
+ if (nameOf(node) === "key" && container?.type === "JSXExpressionContainer" && !isStaticKey(context, container.expression, 0)) context.report({
623
+ node,
624
+ message: STATIC_KEYS_MESSAGE
625
+ });
626
+ } };
627
+ }
628
+ };
629
+ /**
630
+ * Where a JSON string that opens at `at` ends: the index after its closing quote.
631
+ *
632
+ * @param text - The text.
633
+ * @param at - The index of the opening quote.
634
+ * @returns The index after the closing quote, or the length for a string left open.
635
+ * @example
636
+ * ```ts
637
+ * stringEnd('"a\\"b" x', 0); // 6
638
+ * ```
639
+ */
640
+ function stringEnd(text, at) {
641
+ let index = at + 1;
642
+ while (index < text.length && text.charAt(index) !== "\"") index += text.charAt(index) === "\\" ? 2 : 1;
643
+ return Math.min(index + 1, text.length);
644
+ }
645
+ /**
646
+ * JSONC without its comments; a comment inside a string stays.
647
+ *
648
+ * @param text - The JSONC text.
649
+ * @returns The text with every `//` and `/* *\/` comment turned into a space.
650
+ * @example
651
+ * ```ts
652
+ * withoutComments('{ "a": "//" } // note'); // '{ "a": "//" } '
653
+ * ```
654
+ */
655
+ function withoutComments(text) {
656
+ let json = "";
657
+ let index = 0;
658
+ while (index < text.length) if (text.charAt(index) === "\"") {
659
+ const end = stringEnd(text, index);
660
+ json += text.slice(index, end);
661
+ index = end;
662
+ } else if (text.startsWith("//", index)) {
663
+ const end = text.indexOf("\n", index);
664
+ json += " ";
665
+ index = end === -1 ? text.length : end;
666
+ } else if (text.startsWith("/*", index)) {
667
+ const end = text.indexOf("*/", index + 2);
668
+ json += " ";
669
+ index = end === -1 ? text.length : end + 2;
670
+ } else {
671
+ json += text.charAt(index);
672
+ index += 1;
673
+ }
674
+ return json;
675
+ }
676
+ /**
677
+ * Whether the next character after the blanks from an index closes an object or an array.
678
+ *
679
+ * @param json - The text.
680
+ * @param from - Where to look from.
681
+ * @returns True before `}` or `]`.
682
+ * @example
683
+ * ```ts
684
+ * closesNext("1,\n]", 2); // true
685
+ * ```
686
+ */
687
+ function closesNext(json, from) {
688
+ let index = from;
689
+ while (/\s/.test(json.charAt(index))) index += 1;
690
+ return json.charAt(index) === "}" || json.charAt(index) === "]";
691
+ }
692
+ /**
693
+ * JSON without its trailing commas; a comma inside a string stays.
694
+ *
695
+ * @param json - JSON without comments.
696
+ * @returns The text with every comma before a `}` or `]` dropped.
697
+ * @example
698
+ * ```ts
699
+ * withoutTrailingCommas('{ "a": [1, 2,], }'); // '{ "a": [1, 2] }'
700
+ * ```
701
+ */
702
+ function withoutTrailingCommas(json) {
703
+ let strict = "";
704
+ let index = 0;
705
+ while (index < json.length) {
706
+ const char = json.charAt(index);
707
+ if (char === "\"") {
708
+ const end = stringEnd(json, index);
709
+ strict += json.slice(index, end);
710
+ index = end;
711
+ } else {
712
+ if (char !== "," || !closesNext(json, index + 1)) strict += char;
713
+ index += 1;
714
+ }
715
+ }
716
+ return strict;
717
+ }
718
+ /**
719
+ * Whether a parsed JSON value is an object.
720
+ *
721
+ * @param value - The value.
722
+ * @returns True for an object that is not an array.
723
+ * @example
724
+ * ```ts
725
+ * isObject({ paths: {} }); // true
726
+ * ```
727
+ */
728
+ function isObject(value) {
729
+ return typeof value === "object" && value !== null && !Array.isArray(value);
730
+ }
731
+ /**
732
+ * A tsconfig as JSON: comments and trailing commas allowed, as TypeScript allows them.
733
+ *
734
+ * @param text - The text of the file.
735
+ * @returns The object, or undefined when the text does not parse to one.
736
+ * @example
737
+ * ```ts
738
+ * parseTsconfig('{ "extends": "./base.json", } // shared'); // { extends: "./base.json" }
739
+ * ```
740
+ */
741
+ function parseTsconfig(text) {
742
+ try {
743
+ const value = JSON.parse(withoutTrailingCommas(withoutComments(text)));
744
+ return isObject(value) ? value : void 0;
745
+ } catch {
746
+ return;
747
+ }
748
+ }
749
+ /**
750
+ * Read and parse a config file.
751
+ *
752
+ * @param file - The absolute path.
753
+ * @returns The object, or undefined when the file cannot be read or does not parse.
754
+ */
755
+ function readConfig(file) {
756
+ try {
757
+ return parseTsconfig(readFileSync(file, "utf8"));
758
+ } catch {
759
+ return;
760
+ }
761
+ }
762
+ /**
763
+ * The stamp of a file: its `mtimeMs`, or `MISSING`.
764
+ *
765
+ * @param file - The absolute path.
766
+ * @returns The stamp.
767
+ */
768
+ function stampOf(file) {
769
+ return statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? MISSING;
770
+ }
771
+ /**
772
+ * The configs a tsconfig extends that the rules follow: relative and absolute paths, in the order
773
+ * written. A package name ends the chain: lint resolves no node module. A path without `.json`
774
+ * that is not there takes the extension, as TypeScript does.
775
+ *
776
+ * @param file - The absolute path of the config.
777
+ * @param value - Its `extends`: a string, an array, or nothing.
778
+ * @returns The absolute paths of the configs it extends.
779
+ */
780
+ function parentsOf(file, value) {
781
+ return (Array.isArray(value) ? value : [value]).filter((name) => typeof name === "string").filter((name) => RELATIVE.test(name) || path.isAbsolute(name)).map((name) => path.resolve(path.dirname(file), name)).map((parent) => {
782
+ return parent.endsWith(".json") || stampOf(parent) !== MISSING ? parent : `${parent}.json`;
783
+ });
784
+ }
785
+ /**
786
+ * Read a config and the configs it extends: the nearest `paths` and `baseUrl` win, and the later
787
+ * entry of an `extends` array wins over the earlier one, as in TypeScript.
788
+ *
789
+ * @param file - The absolute path of the config.
790
+ * @param depth - How many configs deep the chain is.
791
+ * @param stamps - The files read and their stamps; this read adds to it.
792
+ * @returns What the chain says, or the config that does not parse.
793
+ */
794
+ function readChain(file, depth, stamps) {
795
+ const stamp = stampOf(file);
796
+ stamps.set(file, stamp);
797
+ if (stamp === MISSING) return {
798
+ paths: void 0,
799
+ baseUrl: void 0,
800
+ broken: void 0
801
+ };
802
+ const config = readConfig(file);
803
+ if (config === void 0) return {
804
+ paths: void 0,
805
+ baseUrl: void 0,
806
+ broken: file
807
+ };
808
+ const folder = path.dirname(file);
809
+ const options = isObject(config.compilerOptions) ? config.compilerOptions : {};
810
+ let paths = isObject(options.paths) ? {
811
+ entries: options.paths,
812
+ folder
813
+ } : void 0;
814
+ let baseUrl = typeof options.baseUrl === "string" ? path.resolve(folder, options.baseUrl) : void 0;
815
+ for (const parent of parentsOf(file, config.extends).toReversed()) {
816
+ if (paths !== void 0 && baseUrl !== void 0 || depth >= MAX_EXTENDS) break;
817
+ const inherited = readChain(parent, depth + 1, stamps);
818
+ if (inherited.broken !== void 0) return inherited;
819
+ paths ??= inherited.paths;
820
+ baseUrl ??= inherited.baseUrl;
821
+ }
822
+ return {
823
+ paths,
824
+ baseUrl,
825
+ broken: void 0
826
+ };
827
+ }
828
+ /**
829
+ * How many `*` a key or a target holds.
830
+ *
831
+ * @param text - A key or a target of `paths`.
832
+ * @returns The count.
833
+ * @example
834
+ * ```ts
835
+ * starsOf("@features/*"); // 1
836
+ * ```
837
+ */
838
+ function starsOf(text) {
839
+ return text.split("*").length - 1;
840
+ }
841
+ /**
842
+ * The targets of one key of `paths`, absolute: each string target with one `*` at most.
843
+ *
844
+ * @param base - The absolute folder the targets are relative to.
845
+ * @param value - The value of the key.
846
+ * @returns The targets, `*` kept.
847
+ * @example
848
+ * ```ts
849
+ * targetsOf("/game", ["./core/*", 3]); // ["/game/core/*"]
850
+ * ```
851
+ */
852
+ function targetsOf(base, value) {
853
+ if (!Array.isArray(value)) return [];
854
+ return value.filter((target) => typeof target === "string" && starsOf(target) <= 1).map((target) => path.resolve(base, target));
855
+ }
856
+ /**
857
+ * The patterns of a `paths` block: one per key with one `*` at most and a target.
858
+ *
859
+ * @param base - The absolute folder the targets are relative to.
860
+ * @param paths - The `paths` block.
861
+ * @returns The patterns, in the order of the keys.
862
+ * @example
863
+ * ```ts
864
+ * patternsOf("/game", { "@core/*": ["./core/*"] }); // [{ prefix: "@core/", suffix: "", targets: ["/game/core/*"] }]
865
+ * ```
866
+ */
867
+ function patternsOf(base, paths) {
868
+ const patterns = [];
869
+ for (const [key, value] of Object.entries(paths)) {
870
+ const targets = targetsOf(base, value);
871
+ const star = key.indexOf("*");
872
+ if (targets.length === 0 || starsOf(key) > 1) continue;
873
+ patterns.push(star === -1 ? {
874
+ prefix: key,
875
+ targets
876
+ } : {
877
+ prefix: key.slice(0, star),
878
+ suffix: key.slice(star + 1),
879
+ targets
880
+ });
881
+ }
882
+ return patterns;
883
+ }
884
+ /**
885
+ * Read a tsconfig for the layout rules: the patterns of its nearest `paths`, resolved against
886
+ * `baseUrl` when one is set, else against the folder of the config that declares `paths`, as
887
+ * TypeScript and the project index do.
888
+ *
889
+ * @param file - The absolute path of the tsconfig.
890
+ * @returns The read, with the stamps of every config it touched.
891
+ */
892
+ function readTsconfig(file) {
893
+ const stamps = /* @__PURE__ */ new Map();
894
+ const chain = readChain(file, 0, stamps);
895
+ const patterns = chain.paths === void 0 ? [] : patternsOf(chain.baseUrl ?? chain.paths.folder, chain.paths.entries);
896
+ return {
897
+ patterns: patterns.length === 0 ? void 0 : patterns,
898
+ broken: chain.broken,
899
+ stamps
900
+ };
901
+ }
902
+ /**
903
+ * Whether every file of a read still has its stamp: one `statSync` per file.
904
+ *
905
+ * @param read - An earlier read.
906
+ * @returns True when no config moved, appeared or vanished.
907
+ */
908
+ function isFresh(read) {
909
+ for (const [file, stamp] of read.stamps) if (stampOf(file) !== stamp) return false;
910
+ return true;
911
+ }
912
+ /**
913
+ * Whether a `*` key matches a specifier.
914
+ *
915
+ * @param prefix - The text before the `*`.
916
+ * @param suffix - The text after it.
917
+ * @param specifier - The module specifier.
918
+ * @returns True when the specifier starts with the prefix and ends with the suffix.
919
+ * @example
920
+ * ```ts
921
+ * matchesStar("@core/", "", "@core/kit"); // true
922
+ * ```
923
+ */
924
+ function matchesStar(prefix, suffix, specifier) {
925
+ return specifier.length >= prefix.length + suffix.length && specifier.startsWith(prefix) && specifier.endsWith(suffix);
926
+ }
927
+ /**
928
+ * Whether a specifier is never an alias: a path relative to the importing file, an absolute path,
929
+ * or the engine.
930
+ *
931
+ * @param specifier - The module specifier as written.
932
+ * @returns True when no key of `paths` may match it.
933
+ * @example
934
+ * ```ts
935
+ * isNeverAlias("@moku-labs/game/testing"); // true
936
+ * ```
937
+ */
938
+ function isNeverAlias(specifier) {
939
+ return NOT_BARE.test(specifier) || ENGINE.test(specifier);
940
+ }
941
+ /**
942
+ * Whether a key of `paths` is a `*` key that matches a specifier with a longer prefix than the
943
+ * best match so far.
944
+ *
945
+ * @param pattern - A key of `paths`.
946
+ * @param best - The best `*` match so far, if any.
947
+ * @param specifier - The module specifier.
948
+ * @returns True when the key is the better match.
949
+ * @example
950
+ * ```ts
951
+ * isBetterMatch({ prefix: "@core/", suffix: "", targets: ["/game/core/*"] }, undefined, "@core/kit"); // true
952
+ * ```
953
+ */
954
+ function isBetterMatch(pattern, best, specifier) {
955
+ const isLonger = best === void 0 || pattern.prefix.length > best.prefix.length;
956
+ return pattern.suffix !== void 0 && isLonger && matchesStar(pattern.prefix, pattern.suffix, specifier);
957
+ }
958
+ /**
959
+ * The targets a specifier maps to, the way TypeScript maps it: a key without `*` equal to the
960
+ * specifier wins; else the `*` key with the longest prefix, its `*` filled into every target. A
961
+ * copy of `aliasTargets` in `src/project/aliases.ts`, which reads the tsconfig with TypeScript; this
962
+ * file stays free of it. A behavioural unit test keeps the two in step.
963
+ *
964
+ * @param patterns - The aliases.
965
+ * @param specifier - The module specifier as written.
966
+ * @returns The absolute targets; `[]` for a relative specifier, the engine and no match.
967
+ * @example
968
+ * ```ts
969
+ * targetsFor([{ prefix: "@core/", suffix: "", targets: ["/game/core/*"] }], "@core/kit"); // ["/game/core/kit"]
970
+ * ```
971
+ */
972
+ function targetsFor(patterns, specifier) {
973
+ if (isNeverAlias(specifier)) return [];
974
+ const exact = patterns.find((pattern) => pattern.suffix === void 0 && pattern.prefix === specifier);
975
+ if (exact !== void 0) return [...exact.targets];
976
+ let best;
977
+ for (const pattern of patterns) if (isBetterMatch(pattern, best, specifier)) best = pattern;
978
+ if (best === void 0) return [];
979
+ const hole = specifier.slice(best.prefix.length, specifier.length - best.suffix.length);
980
+ return best.targets.map((target) => target.replace("*", () => hole));
981
+ }
982
+ /**
983
+ * The files a module specifier names through the aliases the layout rules read: the nearest
984
+ * `paths` of the tsconfig and the configs it extends by relative path, else the v15 aliases under
985
+ * the game root. A specifier is matched as TypeScript matches it; `@moku-labs/game` is a package
986
+ * even when the paths map it. The project index maps a specifier the same way, and a unit test
987
+ * keeps the two in step.
988
+ *
989
+ * @param tsconfig - The absolute path of the tsconfig.
990
+ * @param root - The absolute folder of the layers; the v15 aliases resolve against it.
991
+ * @param specifier - The module specifier as written.
992
+ * @returns The absolute targets in the order of the tsconfig; `[]` for a relative specifier, the
993
+ * engine and a package.
994
+ * @example
995
+ * ```ts
996
+ * // A game without a tsconfig reads the v15 aliases.
997
+ * aliasTargetsOf("/game/tsconfig.json", "/game", "@features/orders"); // ["/game/features/orders/index.ts"]
998
+ * ```
999
+ */
1000
+ function aliasTargetsOf(tsconfig, root, specifier) {
1001
+ return targetsFor(readTsconfig(tsconfig).patterns ?? patternsOf(root, V15_PATHS), specifier);
1002
+ }
1003
+ /**
1004
+ * Whether the parts of a path below a unit folder name its door: nothing, or `index` alone.
1005
+ *
1006
+ * @param inner - The parts below the unit folder.
1007
+ * @returns True for the unit folder itself and its `index` file.
1008
+ * @example
1009
+ * ```ts
1010
+ * isDoor(["index.ts"]); // true
1011
+ * ```
1012
+ */
1013
+ function isDoor(inner) {
1014
+ return inner.length === 0 || inner.length === 1 && INDEX_FILE.test(inner[0] ?? "");
1015
+ }
1016
+ /**
1017
+ * The place of a path under `features/` or `plugins/`: the barrel, a unit and its door, or a
1018
+ * file that lies directly in the folder, which is outside the layout.
1019
+ *
1020
+ * @param layer - `features` or `plugins`.
1021
+ * @param below - The parts of the path below the layer folder.
1022
+ * @returns The place.
1023
+ * @example
1024
+ * ```ts
1025
+ * unitOf("features", ["orders", "index.ts"]); // { layer: "features", unit: "features/orders", door: true }
1026
+ * ```
1027
+ */
1028
+ function unitOf(layer, below) {
1029
+ const [name, ...inner] = below;
1030
+ if (name === void 0 || inner.length === 0 && INDEX_FILE.test(name)) return {
1031
+ layer,
1032
+ unit: layer,
1033
+ door: true
1034
+ };
1035
+ if (inner.length === 0 && path.extname(name) !== "") return OTHER;
1036
+ return {
1037
+ layer,
1038
+ unit: `${layer}/${name}`,
1039
+ door: isDoor(inner)
1040
+ };
1041
+ }
1042
+ /**
1043
+ * Where a path lies in the layout of a game.
1044
+ *
1045
+ * @param root - The absolute folder of the layers.
1046
+ * @param file - The absolute path, with or without its extension.
1047
+ * @returns The place; `other` for a path outside the layout.
1048
+ * @example
1049
+ * ```ts
1050
+ * placeOf("/game", "/game/shared/index.ts"); // { layer: "shared", unit: "shared", door: true }
1051
+ * ```
1052
+ */
1053
+ function placeOf(root, file) {
1054
+ const relative = insideOf(root, file);
1055
+ if (relative === void 0) return OTHER;
1056
+ const [head = "", ...below] = relative.split("/");
1057
+ switch (head) {
1058
+ case "core": return {
1059
+ layer: "core",
1060
+ unit: "core",
1061
+ door: false
1062
+ };
1063
+ case "shared": return {
1064
+ layer: "shared",
1065
+ unit: "shared",
1066
+ door: isDoor(below)
1067
+ };
1068
+ case "features":
1069
+ case "plugins": return unitOf(head, below);
1070
+ case "generated":
1071
+ case "tests": return {
1072
+ layer: head,
1073
+ door: false
1074
+ };
1075
+ default: return GAME_FILE.test(relative) ? {
1076
+ layer: "game",
1077
+ door: false
1078
+ } : OTHER;
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Whether a place is one of the two barrels, `features/index.ts` or `plugins/index.ts`.
1083
+ *
1084
+ * @param place - A place.
1085
+ * @returns True for a barrel.
1086
+ * @example
1087
+ * ```ts
1088
+ * isBarrel({ layer: "features", unit: "features", door: true }); // true
1089
+ * ```
1090
+ */
1091
+ function isBarrel(place) {
1092
+ return (place.layer === "features" || place.layer === "plugins") && place.unit === place.layer;
1093
+ }
1094
+ /**
1095
+ * What a layout rule knows about the file the context lints: the layers, the aliases and where
1096
+ * the file lies.
1097
+ *
1098
+ * @param context - The rule context.
1099
+ * @param reads - The tsconfig reads of the rule, by absolute path.
1100
+ * @returns The layout.
1101
+ */
1102
+ function layoutOf(context, reads) {
1103
+ const [given] = context.options;
1104
+ const root = path.resolve(context.cwd, given?.root ?? ".");
1105
+ const tsconfig = path.resolve(context.cwd, given?.tsconfig ?? "tsconfig.json");
1106
+ const file = path.resolve(context.cwd, context.filename);
1107
+ let read = reads.get(tsconfig);
1108
+ if (read === void 0 || !isFresh(read)) {
1109
+ read = readTsconfig(tsconfig);
1110
+ reads.set(tsconfig, read);
1111
+ }
1112
+ const broken = read.broken === void 0 ? void 0 : path.relative(context.cwd, read.broken);
1113
+ return {
1114
+ root,
1115
+ folder: path.dirname(file),
1116
+ importer: placeOf(root, file),
1117
+ patterns: read.patterns ?? patternsOf(root, V15_PATHS),
1118
+ problem: broken === void 0 ? void 0 : `Lint: ${toPosix(broken)} does not parse. The layout rules read the v15 aliases until it does.`
1119
+ };
1120
+ }
1121
+ /**
1122
+ * The file an import names: a relative specifier joined to the folder of the file, an alias to its
1123
+ * first target.
1124
+ *
1125
+ * @param layout - The layout of the importing file.
1126
+ * @param specifier - The module specifier as written.
1127
+ * @returns The absolute target, or undefined for a package.
1128
+ */
1129
+ function targetOf(layout, specifier) {
1130
+ if (RELATIVE.test(specifier)) return path.resolve(layout.folder, specifier);
1131
+ return targetsFor(layout.patterns, specifier)[0];
1132
+ }
1133
+ /**
1134
+ * Build a layout rule: it reads where the layers sit and the aliases, then judges each import,
1135
+ * export-from and `import()`, type-only statements too. The rule keeps the tsconfig it read for
1136
+ * the files of one lint run; the stamps of the configs keep the copy fresh.
1137
+ *
1138
+ * @param description - What the rule enforces.
1139
+ * @param defaults - The rule's default globs.
1140
+ * @param judge - The message for one import, or undefined when it passes.
1141
+ * @returns The rule.
1142
+ */
1143
+ function layoutRule(description, defaults, judge) {
1144
+ const reads = /* @__PURE__ */ new Map();
1145
+ return {
1146
+ meta: {
1147
+ type: "problem",
1148
+ docs: { description },
1149
+ schema: LAYOUT_SCHEMA
1150
+ },
1151
+ /**
1152
+ * Visit the file when the rule's globs match it.
1153
+ *
1154
+ * @param context - The rule context.
1155
+ * @returns The node visitors, none for a file out of scope.
1156
+ */
1157
+ create(context) {
1158
+ if (!inScope(context, defaults)) return {};
1159
+ const layout = layoutOf(context, reads);
1160
+ const check = (node) => {
1161
+ const specifier = specifierOf(node);
1162
+ const message = specifier === void 0 ? void 0 : judge(layout, specifier);
1163
+ if (message !== void 0) context.report({
1164
+ node,
1165
+ message
1166
+ });
1167
+ };
1168
+ return {
1169
+ Program: (node) => {
1170
+ if (layout.problem !== void 0) context.report({
1171
+ node,
1172
+ message: layout.problem
1173
+ });
1174
+ },
1175
+ ImportDeclaration: check,
1176
+ ExportAllDeclaration: check,
1177
+ ExportNamedDeclaration: check,
1178
+ ImportExpression: check
1179
+ };
1180
+ }
1181
+ };
1182
+ }
1183
+ /**
1184
+ * What rules-siblings says about one import of a rule: a sibling, `@core/types` and
1185
+ * `@shared/rules` pass, by alias or by relative path.
1186
+ *
1187
+ * @param layout - The layout of the rule's file.
1188
+ * @param specifier - The module specifier as written.
1189
+ * @returns The report, or undefined when the import passes.
1190
+ */
1191
+ function siblingMessage(layout, specifier) {
1192
+ if (specifier.startsWith("./") || RULE_ALIASES.test(specifier)) return void 0;
1193
+ const target = targetOf(layout, specifier);
1194
+ const relative = target === void 0 ? void 0 : insideOf(layout.root, target);
1195
+ return RULE_IMPORTS.some((pattern) => pattern.test(relative ?? "")) ? void 0 : SIBLINGS_MESSAGE;
1196
+ }
1197
+ /**
1198
+ * What layer-imports says about one import: a layer imports the layers below it only.
1199
+ *
1200
+ * @param layout - The layout of the importing file.
1201
+ * @param specifier - The module specifier as written.
1202
+ * @returns The report, or undefined when the import passes.
1203
+ */
1204
+ function layerMessage(layout, specifier) {
1205
+ const reach = LAYER_REACH[layout.importer.layer];
1206
+ const target = targetOf(layout, specifier);
1207
+ if (reach === void 0 || target === void 0) return void 0;
1208
+ const { layer } = placeOf(layout.root, target);
1209
+ if (layer === "other" || reach.includes(layer)) return void 0;
1210
+ const name = layer === "game" ? "game.ts" : layer;
1211
+ return `Layers: ${layout.importer.layer} does not import ${name}. ${LAYER_ORDER}`;
1212
+ }
1213
+ /**
1214
+ * What feature-door says about an import that goes deep into another feature or plugin.
1215
+ *
1216
+ * @param importer - Where the importing file lies.
1217
+ * @param place - Where the import lands.
1218
+ * @returns The report, or undefined when the import lands on a door or in no unit of another.
1219
+ * @example
1220
+ * ```ts
1221
+ * deepMessage({ layer: "features", unit: "features/home", door: false }, { layer: "features", unit: "features/orders", door: false });
1222
+ * // "Door: another feature is imported from its index.ts only: @features/orders."
1223
+ * ```
1224
+ */
1225
+ function deepMessage(importer, place) {
1226
+ if (!(place.layer === "features" || place.layer === "plugins") || place.door || place.unit === importer.unit) return void 0;
1227
+ return `Door: another ${place.layer === "features" ? "feature" : "plugin"} is imported from its index.ts only: @${place.unit}.`;
1228
+ }
1229
+ /**
1230
+ * What feature-door says about a unit that imports its own door.
1231
+ *
1232
+ * @param importer - Where the importing file lies.
1233
+ * @returns The report.
1234
+ * @example
1235
+ * ```ts
1236
+ * ownDoorMessage({ layer: "shared", unit: "shared", door: false });
1237
+ * // "Door: shared does not import its own index.ts. Import the file: ./flow/merge."
1238
+ * ```
1239
+ */
1240
+ function ownDoorMessage(importer) {
1241
+ return `Door: ${{
1242
+ features: "a feature",
1243
+ plugins: "a plugin"
1244
+ }[importer.layer] ?? importer.layer} does not import its own index.ts. Import the file: ./flow/merge.`;
1245
+ }
1246
+ /**
1247
+ * What feature-door says about a relative import that leaves its unit.
1248
+ *
1249
+ * @param importer - Where the importing file lies.
1250
+ * @returns The report.
1251
+ * @example
1252
+ * ```ts
1253
+ * escapeMessage({ layer: "features", unit: "features/orders", door: false });
1254
+ * // "Door: an import that leaves features/orders is written as an alias: @features/<f>, @shared, @core/<file>."
1255
+ * ```
1256
+ */
1257
+ function escapeMessage(importer) {
1258
+ return `Door: an import that leaves ${importer.unit ?? importer.layer} is written as an alias: @features/<f>, @shared, @core/<file>.`;
1259
+ }
1260
+ /**
1261
+ * What feature-door says about an import of shared, a feature, a plugin or a barrel that lands in
1262
+ * the layout outside the barrels.
1263
+ *
1264
+ * @param importer - Where the importing file lies.
1265
+ * @param place - Where the import lands.
1266
+ * @param isRelative - True for a relative specifier.
1267
+ * @returns The report, or undefined when the import passes.
1268
+ * @example
1269
+ * ```ts
1270
+ * unitDoorMessage({ layer: "features", unit: "features", door: true }, { layer: "features", unit: "features/home", door: true }, true); // undefined
1271
+ * ```
1272
+ */
1273
+ function unitDoorMessage(importer, place, isRelative) {
1274
+ const deep = deepMessage(importer, place);
1275
+ if (deep !== void 0) return deep;
1276
+ if (place.unit === importer.unit) return place.door && !importer.door ? ownDoorMessage(importer) : void 0;
1277
+ const staysInBarrel = isBarrel(importer) && place.layer === importer.layer;
1278
+ return isRelative && !staysInBarrel ? escapeMessage(importer) : void 0;
1279
+ }
1280
+ /**
1281
+ * What feature-door says about one import of a unit: the barrels belong to game.ts, a unit does
1282
+ * not import its own door, another feature is reached through its door, and an import that
1283
+ * leaves the unit is written as an alias.
1284
+ *
1285
+ * @param layout - The layout of the importing file.
1286
+ * @param specifier - The module specifier as written.
1287
+ * @returns The report, or undefined when the import passes.
1288
+ */
1289
+ function doorMessage(layout, specifier) {
1290
+ const { importer } = layout;
1291
+ const target = targetOf(layout, specifier);
1292
+ if (importer.unit === void 0 || target === void 0) return void 0;
1293
+ const place = placeOf(layout.root, target);
1294
+ const isRelative = RELATIVE.test(specifier);
1295
+ if (place.layer === "other") return void 0;
1296
+ if (isBarrel(place)) return place.unit === importer.unit ? void 0 : BARREL_MESSAGE;
1297
+ if (importer.layer === "core") return isRelative && place.layer !== "core" ? escapeMessage(importer) : void 0;
1298
+ return unitDoorMessage(importer, place, isRelative);
1299
+ }
1300
+ /** L4: a rule imports only its siblings, the types of core and the rules of shared. */
1301
+ const rulesSiblings = layoutRule("A file under rules/ imports only its siblings, @core/types and @shared/rules.", {
1302
+ files: ["**/rules/**"],
1303
+ ignores: TESTS
1304
+ }, siblingMessage);
1305
+ /** Layers: core ← shared ← features ← game.ts; plugins import core and shared. */
1306
+ const layerImports = layoutRule("A layer imports only the layers below it: core ← shared ← features ← game.ts; plugins import core and shared.", {
1307
+ files: ["**"],
1308
+ ignores: [...TESTS, "**/generated/**"]
1309
+ }, layerMessage);
1310
+ /** Doors: a feature is imported from its index.ts, and the barrels only by game.ts. */
1311
+ const featureDoor = layoutRule("A feature is imported from its index.ts; the barrels only by game.ts; an import that leaves a feature is an alias.", {
1312
+ files: ["**/{core,shared,features,plugins}/**"],
1313
+ ignores: TESTS
1314
+ }, doorMessage);
1315
+ /**
1316
+ * The key of a suffix table as a folder: with a trailing `/`.
1317
+ *
1318
+ * @param key - A key of the table.
1319
+ * @returns The folder.
1320
+ * @example
1321
+ * ```ts
1322
+ * folderOf("tests/e2e"); // "tests/e2e/"
1323
+ * ```
1324
+ */
1325
+ function folderOf(key) {
1326
+ return key.endsWith("/") ? key : `${key}/`;
1327
+ }
1328
+ /**
1329
+ * The kind folder of the table that holds a folder: the nearest one, then the longest key. A key
1330
+ * that names a test root itself holds only the files directly inside.
1331
+ *
1332
+ * @param folder - The folder of the file, as `/a/b/`.
1333
+ * @param table - Kind folder to suffix.
1334
+ * @returns The kind, or undefined for a folder no key holds.
1335
+ * @example
1336
+ * ```ts
1337
+ * kindOf("/features/home/__tests__/unit/", { "__tests__/unit/": ".test.ts" })?.suffix; // ".test.ts"
1338
+ * ```
1339
+ */
1340
+ function kindOf(folder, table) {
1341
+ let best;
1342
+ for (const [key, suffix] of Object.entries(table)) {
1343
+ const kind = folderOf(key);
1344
+ const at = folder.lastIndexOf(`/${kind}`);
1345
+ const end = at + kind.length + 1;
1346
+ const holds = at !== -1 && (end === folder.length || !TEST_ROOT_KEY.test(kind));
1347
+ const isBetter = best === void 0 || end > best.end || end === best.end && kind.length > best.folder.length;
1348
+ if (holds && isBetter) best = {
1349
+ folder: kind,
1350
+ suffix,
1351
+ end
1352
+ };
1353
+ }
1354
+ return best;
1355
+ }
1356
+ /**
1357
+ * Whether a test file is a helper: it lies in `helpers/`, `fixtures/` or `baselines/` somewhere
1358
+ * below its test root.
1359
+ *
1360
+ * @param folders - The folders of the file, from the game root.
1361
+ * @returns True for a helper, and for a file outside every test root.
1362
+ * @example
1363
+ * ```ts
1364
+ * isHelper(["tests", "visual", "baselines", "home"]); // true
1365
+ * ```
1366
+ */
1367
+ function isHelper(folders) {
1368
+ const first = folders.findIndex((folder) => TEST_ROOT.test(folder));
1369
+ return first === -1 || folders.slice(first + 1).some((folder) => HELPER_FOLDER.test(folder));
1370
+ }
1371
+ /**
1372
+ * What test-suffix says about a file: a file in a test kind folder ends with that folder's suffix.
1373
+ *
1374
+ * @param file - The file, relative to the game root.
1375
+ * @param table - Kind folder to suffix.
1376
+ * @returns The report, or undefined for a file that passes or is not checked.
1377
+ * @example
1378
+ * ```ts
1379
+ * suffixMessage("tests/e2e/board.test.ts", { "tests/e2e/": ".e2e.ts" });
1380
+ * // "Tests: a file in tests/e2e/ ends with .e2e.ts. Rename board.test.ts to board.e2e.ts, or move a helper to tests/helpers/."
1381
+ * ```
1382
+ */
1383
+ function suffixMessage(file, table) {
1384
+ const name = path.posix.basename(file);
1385
+ const extension = SCRIPT.exec(name)?.[0];
1386
+ const folder = path.posix.dirname(file);
1387
+ const kind = isHelper(folder.split("/")) ? void 0 : kindOf(`/${folder}/`, table);
1388
+ if (extension === void 0 || kind === void 0) return void 0;
1389
+ const suffix = extension === TSX_EXTENSION && kind.suffix.endsWith(TS_EXTENSION) ? `${kind.suffix}x` : kind.suffix;
1390
+ if (name.endsWith(suffix)) return void 0;
1391
+ const renamed = `${name.slice(0, -extension.length).replace(TEST_WORD, "")}${suffix}`;
1392
+ const helpers = kind.folder.includes(COLOCATED_TESTS) ? COLOCATED_HELPERS : ROOT_HELPERS;
1393
+ return `Tests: a file in ${kind.folder} ends with ${suffix}. Rename ${name} to ${renamed}, or move a helper to ${helpers}.`;
1394
+ }
442
1395
  /**
443
1396
  * The `moku-game` oxlint plugin. A game loads it by package name and turns its rules on.
444
1397
  *
@@ -456,33 +1409,37 @@ const plugin = {
456
1409
  "no-module-state": noModuleState,
457
1410
  determinism,
458
1411
  "rules-siblings": rulesSiblings,
459
- "static-keys": {
1412
+ "static-keys": staticKeys,
1413
+ "layer-imports": layerImports,
1414
+ "feature-door": featureDoor,
1415
+ "test-suffix": {
460
1416
  meta: {
461
1417
  type: "problem",
462
- docs: { description: "A JSX key is a literal, a template, props.id or props.<name>Key, or a call." },
463
- schema: SCHEMA
1418
+ docs: { description: "A file in a test kind folder ends with its suffix: .e2e.ts, .visual.ts, .editor.ts, .test.ts, .isolated.ts." },
1419
+ schema: SUFFIX_SCHEMA
464
1420
  },
465
1421
  /**
466
- * Visit the file when the rule's globs match it.
1422
+ * Visit the file when the rule's globs match it and its name misses the suffix.
467
1423
  *
468
1424
  * @param context - The rule context.
469
- * @returns The node visitors, none for a file out of scope.
1425
+ * @returns The node visitors, none for a file out of scope or named right.
470
1426
  */
471
1427
  create(context) {
472
1428
  if (!inScope(context, {
473
- files: ["**/*.tsx"],
474
- ignores: TESTS
1429
+ files: TEST_FOLDERS,
1430
+ ignores: []
475
1431
  })) return {};
476
- return { JSXAttribute: (node) => {
477
- const container = isNode(node.value) ? node.value : void 0;
478
- if (nameOf(node) === "key" && container?.type === "JSXExpressionContainer" && !isStaticKey(context, container.expression, 0)) context.report({
479
- node,
480
- message: STATIC_KEYS_MESSAGE
481
- });
482
- } };
1432
+ const [given] = context.options;
1433
+ const file = insideOf(path.resolve(context.cwd, given?.root ?? "."), path.resolve(context.cwd, context.filename));
1434
+ const message = file === void 0 ? void 0 : suffixMessage(file, given?.suffixes ?? TEST_SUFFIXES);
1435
+ if (message === void 0) return {};
1436
+ return { Program: (node) => context.report({
1437
+ node,
1438
+ message
1439
+ }) };
483
1440
  }
484
1441
  }
485
1442
  }
486
1443
  };
487
1444
  //#endregion
488
- export { plugin as default, globToRegExp };
1445
+ export { aliasTargetsOf, plugin as default, globToRegExp };