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