@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.
- package/README.md +8 -5
- package/dist/assets.d.mts +97 -33
- package/dist/assets.mjs +350 -110
- package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
- package/dist/{commands-C1kIfOG5.d.mts → commands-DuwCxK5t.d.mts} +5 -6
- package/dist/control.d.mts +4 -4
- package/dist/control.mjs +1 -1
- package/dist/{define-BPpZcpxW.d.mts → define-B6pHpdBi.d.mts} +2 -2
- package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
- package/dist/{headless-q98SwADR.d.mts → headless-SCQXJxQf.d.mts} +1 -1
- package/dist/hot.d.mts +4 -2
- package/dist/hot.mjs +8 -6
- package/dist/index-BO9kZzY3.d.mts +1611 -0
- package/dist/index.d.mts +6 -1597
- package/dist/index.mjs +4 -34343
- package/dist/inspect.d.mts +7 -8
- package/dist/inspect.mjs +1 -1
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/lint.d.mts +55 -4
- package/dist/lint.mjs +993 -40
- package/dist/project.d.mts +29 -13
- package/dist/project.mjs +986 -497
- package/dist/{run-CUPSFM85.mjs → run-CofJEdYx.mjs} +2 -2
- package/dist/{runtime-CvDZZg1S.d.mts → runtime-CdNJ0UBm.d.mts} +1 -1
- package/dist/{sources-BBUd15vQ.mjs → sources-DNnzyCYf.mjs} +1 -1
- package/dist/src-536arXjV.mjs +34644 -0
- package/dist/testing.d.mts +130 -3
- package/dist/testing.mjs +192 -3
- package/dist/{types-BWzCSRM2.d.mts → types-B0v5VVa3.d.mts} +2982 -19
- package/dist/{types-DF5kkW-C.d.mts → types-CQueODzo.d.mts} +4 -5
- package/dist/{types-CDSKa0vl.d.mts → types-DUMoYPYe.d.mts} +2 -2
- package/dist/{types-DlgsfOmW.d.mts → types-VZVWwqCg.d.mts} +49 -12
- package/dist/{types-DgdMLZnV.d.mts → types-xXfyk56Q.d.mts} +7 -8
- package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
- package/dist/visual.d.mts +10 -11
- package/dist/visual.mjs +2 -2
- package/llms.txt +55 -18
- package/package.json +1 -1
- package/dist/memory-CvgdnsQO.mjs +0 -259
- package/dist/registry-DlpRCibU.mjs +0 -384
- 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.
|
|
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
|
-
/**
|
|
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
|
-
"**/
|
|
29
|
+
"**/game.ts",
|
|
30
|
+
"**/{core,nodes,flows,rules,features,shared}/**"
|
|
24
31
|
];
|
|
25
|
-
/** The
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
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
|
-
"**/
|
|
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
|
|
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
|
|
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
|
|
463
|
-
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:
|
|
474
|
-
ignores:
|
|
1425
|
+
files: TEST_FOLDERS,
|
|
1426
|
+
ignores: []
|
|
475
1427
|
})) return {};
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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 };
|