@moku-labs/game 0.14.12 → 0.14.13
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 +13 -12
- package/dist/app/page.d.mts +8 -3
- package/dist/app/page.mjs +57 -12
- package/dist/app/system.d.mts +5 -3
- package/dist/app/system.mjs +2 -0
- package/dist/app.d.mts +15 -7
- package/dist/app.mjs +131 -27
- package/dist/{assets-BrKSg1O6.mjs → assets-CEMfBogh.mjs} +471 -120
- package/dist/assets.d.mts +30 -13
- package/dist/assets.mjs +1 -1
- package/dist/cli.d.mts +105 -8
- package/dist/cli.mjs +1036 -128
- package/dist/{commands-BaCx2B9j.d.mts → commands-DAETmK86.d.mts} +43 -7
- package/dist/{config-CdARrrhU.mjs → config-B87aFTW-.mjs} +25 -4
- package/dist/control.d.mts +130 -17
- package/dist/control.mjs +4 -4
- package/dist/{define-Ce8SVEN-.d.mts → define-BASd9ZUh.d.mts} +32 -4
- package/dist/dev-DbHgx-R-.mjs +89 -0
- package/dist/{fake-MpzwZhIZ.mjs → fake-6mazmHK3.mjs} +20 -2
- package/dist/features-DowEilZm.mjs +266 -0
- package/dist/{headless-CMHNsKjW.d.mts → headless-cFveciuY.d.mts} +1 -1
- package/dist/hot.d.mts +4 -2
- package/dist/hot.mjs +7 -4
- package/dist/{index-CLQ8NOTw.d.mts → index-DedbED3T.d.mts} +317 -69
- package/dist/index.d.mts +5 -6
- package/dist/index.mjs +3 -3
- package/dist/inspect.d.mts +12 -7
- package/dist/inspect.mjs +2 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/lint.d.mts +8 -3
- package/dist/lint.mjs +432 -20
- package/dist/project.d.mts +15 -1
- package/dist/project.mjs +1 -1
- package/dist/{run-Bf2hKyjZ.mjs → run-BbipFRc6.mjs} +250 -51
- package/dist/{runtime-BCnKn0xf.d.mts → runtime-DoSlRx8X.d.mts} +1 -1
- package/dist/{session-DgukhR_7.mjs → session-CeKgO2hy.mjs} +30 -2
- package/dist/{sources-Duzj6lhV.mjs → sources-Cdvb0rOS.mjs} +33 -6
- package/dist/{src-CtC8w3_z.mjs → src-BEbJ_sSx.mjs} +35536 -17789
- package/dist/testing.d.mts +23 -13
- package/dist/testing.mjs +10 -6
- package/dist/{types-DOlpeuGx.d.mts → types-7GXcXxxv.d.mts} +174 -242
- package/dist/{types-BjTtGSFb.d.mts → types-BGcda5Le.d.mts} +4459 -732
- package/dist/{types-gisvSHoC.d.mts → types-C73hbmxi.d.mts} +129 -17
- package/dist/{types-CAPv9l29.d.mts → types-DngFQJro.d.mts} +43 -5
- package/dist/{types-Y_rCuXtj.d.mts → types-GHE4J5Uf.d.mts} +1025 -191
- package/dist/{visual-dMW8iqAE.mjs → visual-2m4wJL_z.mjs} +111 -7
- package/dist/visual-_XhoGeIC.d.mts +345 -0
- package/dist/visual.d.mts +1 -332
- package/dist/visual.mjs +130 -20
- package/dist/{watch-CWI_on4T.mjs → watch-BEdrp80i.mjs} +149 -30
- package/llms.txt +99 -48
- package/package.json +8 -6
- package/dist/dev-Cc50Dk7H.mjs +0 -47
- package/dist/memory-pUQ7YZWC.d.mts +0 -50
- package/dist/types-BX4Ge_j5.d.mts +0 -656
package/dist/lint.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as findFeatureUnits, r as ownerOf } from "./features-DowEilZm.mjs";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
//#region src/lint.ts
|
|
4
5
|
/**
|
|
@@ -8,8 +9,9 @@ import path from "node:path";
|
|
|
8
9
|
* use the ESLint rule API, so ESLint 9 loads the same object. Every rule takes
|
|
9
10
|
* `[{ files, ignores }]`: globs relative to the directory oxlint runs in; a key given replaces the
|
|
10
11
|
* 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
|
|
12
|
-
* file is erasable TypeScript, so
|
|
12
|
+
* whose `paths` name the aliases (`tsconfig`). Only `node:path`, `node:fs` and
|
|
13
|
+
* `./project/features.ts` (itself `node:` only) are imported; the file is erasable TypeScript, so
|
|
14
|
+
* Node loads it unbuilt.
|
|
13
15
|
*/
|
|
14
16
|
/** Tests and test helpers: no rule checks them by default. */
|
|
15
17
|
const TESTS = [
|
|
@@ -139,6 +141,17 @@ const MISSING = -1;
|
|
|
139
141
|
const INDEX_FILE = /^index(?:\.[jt]sx?)?$/;
|
|
140
142
|
/** The root flow of a game, with or without its extension. */
|
|
141
143
|
const GAME_FILE = /^game(?:\.[jt]sx?)?$/;
|
|
144
|
+
/** The folders that hold one kind of code or resource of a feature: the list of `src/project/features.ts`. */
|
|
145
|
+
const KIND_FOLDER = /^(?:flow|views|rules|styles|motion|world|effects|nodes|assets|strings|content|__tests__)$/;
|
|
146
|
+
/** A source naming a folder beside the door: `./<folder>`, `./<folder>/index`, either with `.ts`, `.tsx` or `.js`. */
|
|
147
|
+
const FOLDER_SOURCE = /^\.\/([^/]+?)(?:\/index)?(?:\.(?:tsx?|js))?$/;
|
|
148
|
+
/** The default scope of sub-features: the `index` files below `features/`. */
|
|
149
|
+
const DOOR_FILES = {
|
|
150
|
+
files: ["**/features/**/index.{ts,tsx}"],
|
|
151
|
+
ignores: TESTS
|
|
152
|
+
};
|
|
153
|
+
/** What sub-features says about an entry of `features: [...]` that is not a name. */
|
|
154
|
+
const ENTRY_SHAPE_MESSAGE = "Sub-features: list each sub-feature by the name it is imported under from \"./<folder>\".";
|
|
142
155
|
/** The place of every path outside the layout. */
|
|
143
156
|
const OTHER = {
|
|
144
157
|
layer: "other",
|
|
@@ -1014,18 +1027,61 @@ function isDoor(inner) {
|
|
|
1014
1027
|
return inner.length === 0 || inner.length === 1 && INDEX_FILE.test(inner[0] ?? "");
|
|
1015
1028
|
}
|
|
1016
1029
|
/**
|
|
1030
|
+
* The units of the features a unit lives in, nearest first: the folders above it, one per depth.
|
|
1031
|
+
*
|
|
1032
|
+
* @param dir - The unit's root-relative folder.
|
|
1033
|
+
* @param depth - The unit's depth: 0 for a folder of `features/`.
|
|
1034
|
+
* @returns The folders of its ancestors.
|
|
1035
|
+
* @example
|
|
1036
|
+
* ```ts
|
|
1037
|
+
* ancestorsOf("features/episodes/ep01/scene1", 2); // ["features/episodes/ep01", "features/episodes"]
|
|
1038
|
+
* ```
|
|
1039
|
+
*/
|
|
1040
|
+
function ancestorsOf(dir, depth) {
|
|
1041
|
+
const parts = dir.split("/");
|
|
1042
|
+
return Array.from({ length: depth }, (_, index) => parts.slice(0, -1 - index).join("/"));
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* The place of a path inside a feature unit: the unit, its door, and the units it lives in.
|
|
1046
|
+
*
|
|
1047
|
+
* @param owner - The deepest unit that holds the path.
|
|
1048
|
+
* @param below - The parts of the path below the `features` folder.
|
|
1049
|
+
* @returns The place.
|
|
1050
|
+
* @example
|
|
1051
|
+
* ```ts
|
|
1052
|
+
* unitPlace({ name: "ep01", dir: "features/episodes/ep01", depth: 1, resources: { assets: false, strings: true } }, ["episodes", "ep01", "index.ts"]);
|
|
1053
|
+
* // { layer: "features", unit: "features/episodes/ep01", door: true, ancestors: ["features/episodes"] }
|
|
1054
|
+
* ```
|
|
1055
|
+
*/
|
|
1056
|
+
function unitPlace(owner, below) {
|
|
1057
|
+
const door = isDoor(below.slice(owner.dir.split("/").length - 1));
|
|
1058
|
+
if (owner.depth === 0) return {
|
|
1059
|
+
layer: "features",
|
|
1060
|
+
unit: owner.dir,
|
|
1061
|
+
door
|
|
1062
|
+
};
|
|
1063
|
+
return {
|
|
1064
|
+
layer: "features",
|
|
1065
|
+
unit: owner.dir,
|
|
1066
|
+
door,
|
|
1067
|
+
ancestors: ancestorsOf(owner.dir, owner.depth)
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1017
1071
|
* The place of a path under `features/` or `plugins/`: the barrel, a unit and its door, or a
|
|
1018
|
-
* file that lies directly in the folder, which is outside the layout.
|
|
1072
|
+
* file that lies directly in the folder, which is outside the layout. The unit of a features path
|
|
1073
|
+
* is the deepest feature unit that holds it; with none on disk, the folder of `features/` it lies in.
|
|
1019
1074
|
*
|
|
1020
1075
|
* @param layer - `features` or `plugins`.
|
|
1021
1076
|
* @param below - The parts of the path below the layer folder.
|
|
1077
|
+
* @param owners - The feature units of the game.
|
|
1022
1078
|
* @returns The place.
|
|
1023
1079
|
* @example
|
|
1024
1080
|
* ```ts
|
|
1025
|
-
* unitOf("features", ["orders", "index.ts"]); // { layer: "features", unit: "features/orders", door: true }
|
|
1081
|
+
* unitOf("features", ["orders", "index.ts"], []); // { layer: "features", unit: "features/orders", door: true }
|
|
1026
1082
|
* ```
|
|
1027
1083
|
*/
|
|
1028
|
-
function unitOf(layer, below) {
|
|
1084
|
+
function unitOf(layer, below, owners) {
|
|
1029
1085
|
const [name, ...inner] = below;
|
|
1030
1086
|
if (name === void 0 || inner.length === 0 && INDEX_FILE.test(name)) return {
|
|
1031
1087
|
layer,
|
|
@@ -1033,6 +1089,8 @@ function unitOf(layer, below) {
|
|
|
1033
1089
|
door: true
|
|
1034
1090
|
};
|
|
1035
1091
|
if (inner.length === 0 && path.extname(name) !== "") return OTHER;
|
|
1092
|
+
const owner = layer === "features" ? ownerOf(owners, [layer, ...below].join("/")) : void 0;
|
|
1093
|
+
if (owner !== void 0) return unitPlace(owner, below);
|
|
1036
1094
|
return {
|
|
1037
1095
|
layer,
|
|
1038
1096
|
unit: `${layer}/${name}`,
|
|
@@ -1044,13 +1102,14 @@ function unitOf(layer, below) {
|
|
|
1044
1102
|
*
|
|
1045
1103
|
* @param root - The absolute folder of the layers.
|
|
1046
1104
|
* @param file - The absolute path, with or without its extension.
|
|
1105
|
+
* @param owners - The feature units of the game; none puts a features path in its top-level folder.
|
|
1047
1106
|
* @returns The place; `other` for a path outside the layout.
|
|
1048
1107
|
* @example
|
|
1049
1108
|
* ```ts
|
|
1050
1109
|
* placeOf("/game", "/game/shared/index.ts"); // { layer: "shared", unit: "shared", door: true }
|
|
1051
1110
|
* ```
|
|
1052
1111
|
*/
|
|
1053
|
-
function placeOf(root, file) {
|
|
1112
|
+
function placeOf(root, file, owners = []) {
|
|
1054
1113
|
const relative = insideOf(root, file);
|
|
1055
1114
|
if (relative === void 0) return OTHER;
|
|
1056
1115
|
const [head = "", ...below] = relative.split("/");
|
|
@@ -1066,7 +1125,7 @@ function placeOf(root, file) {
|
|
|
1066
1125
|
door: isDoor(below)
|
|
1067
1126
|
};
|
|
1068
1127
|
case "features":
|
|
1069
|
-
case "plugins": return unitOf(head, below);
|
|
1128
|
+
case "plugins": return unitOf(head, below, owners);
|
|
1070
1129
|
case "generated":
|
|
1071
1130
|
case "tests": return {
|
|
1072
1131
|
layer: head,
|
|
@@ -1092,14 +1151,35 @@ function isBarrel(place) {
|
|
|
1092
1151
|
return (place.layer === "features" || place.layer === "plugins") && place.unit === place.layer;
|
|
1093
1152
|
}
|
|
1094
1153
|
/**
|
|
1154
|
+
* The feature units of a game. A name clash leaves none, and says why.
|
|
1155
|
+
*
|
|
1156
|
+
* @param root - The absolute folder of the layers.
|
|
1157
|
+
* @returns The units, or none and the report of the clash.
|
|
1158
|
+
*/
|
|
1159
|
+
function readUnits(root) {
|
|
1160
|
+
try {
|
|
1161
|
+
return {
|
|
1162
|
+
owners: findFeatureUnits(root),
|
|
1163
|
+
problem: void 0
|
|
1164
|
+
};
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
const [first = ""] = (error instanceof Error ? error.message : "").split("\n");
|
|
1167
|
+
return {
|
|
1168
|
+
owners: [],
|
|
1169
|
+
problem: `Lint: ${first.replace(/^\[game\] /, "")} The layout rules read the top-level features only until it is fixed.`
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1095
1174
|
* What a layout rule knows about the file the context lints: the layers, the aliases and where
|
|
1096
1175
|
* the file lies.
|
|
1097
1176
|
*
|
|
1098
1177
|
* @param context - The rule context.
|
|
1099
1178
|
* @param reads - The tsconfig reads of the rule, by absolute path.
|
|
1179
|
+
* @param units - True to read the feature units, once per file.
|
|
1100
1180
|
* @returns The layout.
|
|
1101
1181
|
*/
|
|
1102
|
-
function layoutOf(context, reads) {
|
|
1182
|
+
function layoutOf(context, reads, units) {
|
|
1103
1183
|
const [given] = context.options;
|
|
1104
1184
|
const root = path.resolve(context.cwd, given?.root ?? ".");
|
|
1105
1185
|
const tsconfig = path.resolve(context.cwd, given?.tsconfig ?? "tsconfig.json");
|
|
@@ -1110,12 +1190,19 @@ function layoutOf(context, reads) {
|
|
|
1110
1190
|
reads.set(tsconfig, read);
|
|
1111
1191
|
}
|
|
1112
1192
|
const broken = read.broken === void 0 ? void 0 : path.relative(context.cwd, read.broken);
|
|
1193
|
+
const owners = units ? readUnits(root) : {
|
|
1194
|
+
owners: [],
|
|
1195
|
+
problem: void 0
|
|
1196
|
+
};
|
|
1197
|
+
const importer = placeOf(root, file, owners.owners);
|
|
1113
1198
|
return {
|
|
1114
1199
|
root,
|
|
1115
1200
|
folder: path.dirname(file),
|
|
1116
|
-
importer
|
|
1201
|
+
importer,
|
|
1117
1202
|
patterns: read.patterns ?? patternsOf(root, V15_PATHS),
|
|
1118
|
-
problem: broken === void 0 ? void 0 : `Lint: ${toPosix(broken)} does not parse. The layout rules read the v15 aliases until it does
|
|
1203
|
+
problem: broken === void 0 ? void 0 : `Lint: ${toPosix(broken)} does not parse. The layout rules read the v15 aliases until it does.`,
|
|
1204
|
+
owners: owners.owners,
|
|
1205
|
+
ownersProblem: importer.layer === "features" ? owners.problem : void 0
|
|
1119
1206
|
};
|
|
1120
1207
|
}
|
|
1121
1208
|
/**
|
|
@@ -1138,9 +1225,10 @@ function targetOf(layout, specifier) {
|
|
|
1138
1225
|
* @param description - What the rule enforces.
|
|
1139
1226
|
* @param defaults - The rule's default globs.
|
|
1140
1227
|
* @param judge - The message for one import, or undefined when it passes.
|
|
1228
|
+
* @param units - True for a rule that reads the feature units and reports a name clash.
|
|
1141
1229
|
* @returns The rule.
|
|
1142
1230
|
*/
|
|
1143
|
-
function layoutRule(description, defaults, judge) {
|
|
1231
|
+
function layoutRule(description, defaults, judge, units = false) {
|
|
1144
1232
|
const reads = /* @__PURE__ */ new Map();
|
|
1145
1233
|
return {
|
|
1146
1234
|
meta: {
|
|
@@ -1156,7 +1244,7 @@ function layoutRule(description, defaults, judge) {
|
|
|
1156
1244
|
*/
|
|
1157
1245
|
create(context) {
|
|
1158
1246
|
if (!inScope(context, defaults)) return {};
|
|
1159
|
-
const layout = layoutOf(context, reads);
|
|
1247
|
+
const layout = layoutOf(context, reads, units);
|
|
1160
1248
|
const check = (node) => {
|
|
1161
1249
|
const specifier = specifierOf(node);
|
|
1162
1250
|
const message = specifier === void 0 ? void 0 : judge(layout, specifier);
|
|
@@ -1171,6 +1259,10 @@ function layoutRule(description, defaults, judge) {
|
|
|
1171
1259
|
node,
|
|
1172
1260
|
message: layout.problem
|
|
1173
1261
|
});
|
|
1262
|
+
if (layout.ownersProblem !== void 0) context.report({
|
|
1263
|
+
node,
|
|
1264
|
+
message: layout.ownersProblem
|
|
1265
|
+
});
|
|
1174
1266
|
},
|
|
1175
1267
|
ImportDeclaration: check,
|
|
1176
1268
|
ExportAllDeclaration: check,
|
|
@@ -1205,7 +1297,7 @@ function layerMessage(layout, specifier) {
|
|
|
1205
1297
|
const reach = LAYER_REACH[layout.importer.layer];
|
|
1206
1298
|
const target = targetOf(layout, specifier);
|
|
1207
1299
|
if (reach === void 0 || target === void 0) return void 0;
|
|
1208
|
-
const { layer } = placeOf(layout.root, target);
|
|
1300
|
+
const { layer } = placeOf(layout.root, target, layout.owners);
|
|
1209
1301
|
if (layer === "other" || reach.includes(layer)) return void 0;
|
|
1210
1302
|
const name = layer === "game" ? "game.ts" : layer;
|
|
1211
1303
|
return `Layers: ${layout.importer.layer} does not import ${name}. ${LAYER_ORDER}`;
|
|
@@ -1258,6 +1350,22 @@ function escapeMessage(importer) {
|
|
|
1258
1350
|
return `Door: an import that leaves ${importer.unit ?? importer.layer} is written as an alias: @features/<f>, @shared, @core/<file>.`;
|
|
1259
1351
|
}
|
|
1260
1352
|
/**
|
|
1353
|
+
* What feature-door says about a sub-feature that imports a feature it lives in: the fix is a
|
|
1354
|
+
* sibling kit of the feature it imported.
|
|
1355
|
+
*
|
|
1356
|
+
* @param importer - The unit of the importing file.
|
|
1357
|
+
* @param ancestor - The unit of the feature it imports.
|
|
1358
|
+
* @returns The report.
|
|
1359
|
+
* @example
|
|
1360
|
+
* ```ts
|
|
1361
|
+
* ancestorMessage("features/episodes/ep01", "features/episodes");
|
|
1362
|
+
* // "Door: a sub-feature does not import the feature it lives in: features/episodes/ep01 imports features/episodes. Move what they share into a sibling sub-feature, such as features/episodes/episodes-kit, and import it from its door."
|
|
1363
|
+
* ```
|
|
1364
|
+
*/
|
|
1365
|
+
function ancestorMessage(importer, ancestor) {
|
|
1366
|
+
return `Door: a sub-feature does not import the feature it lives in: ${importer} imports ${ancestor}. Move what they share into a sibling sub-feature, such as ${`${ancestor}/${path.posix.basename(ancestor)}-kit`}, and import it from its door.`;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1261
1369
|
* What feature-door says about an import of shared, a feature, a plugin or a barrel that lands in
|
|
1262
1370
|
* the layout outside the barrels.
|
|
1263
1371
|
*
|
|
@@ -1274,13 +1382,14 @@ function unitDoorMessage(importer, place, isRelative) {
|
|
|
1274
1382
|
const deep = deepMessage(importer, place);
|
|
1275
1383
|
if (deep !== void 0) return deep;
|
|
1276
1384
|
if (place.unit === importer.unit) return place.door && !importer.door ? ownDoorMessage(importer) : void 0;
|
|
1385
|
+
if (place.door && place.ancestors?.includes(importer.unit ?? "") === true) return void 0;
|
|
1277
1386
|
const staysInBarrel = isBarrel(importer) && place.layer === importer.layer;
|
|
1278
1387
|
return isRelative && !staysInBarrel ? escapeMessage(importer) : void 0;
|
|
1279
1388
|
}
|
|
1280
1389
|
/**
|
|
1281
|
-
* What feature-door says about one import of a unit: the barrels belong to game.ts, a
|
|
1282
|
-
* not import
|
|
1283
|
-
* leaves the unit is written as an alias.
|
|
1390
|
+
* What feature-door says about one import of a unit: the barrels belong to game.ts, a sub-feature
|
|
1391
|
+
* does not import a feature it lives in, a unit does not import its own door, another feature is
|
|
1392
|
+
* reached through its door, and an import that leaves the unit is written as an alias.
|
|
1284
1393
|
*
|
|
1285
1394
|
* @param layout - The layout of the importing file.
|
|
1286
1395
|
* @param specifier - The module specifier as written.
|
|
@@ -1290,11 +1399,12 @@ function doorMessage(layout, specifier) {
|
|
|
1290
1399
|
const { importer } = layout;
|
|
1291
1400
|
const target = targetOf(layout, specifier);
|
|
1292
1401
|
if (importer.unit === void 0 || target === void 0) return void 0;
|
|
1293
|
-
const place = placeOf(layout.root, target);
|
|
1402
|
+
const place = placeOf(layout.root, target, layout.owners);
|
|
1294
1403
|
const isRelative = RELATIVE.test(specifier);
|
|
1295
1404
|
if (place.layer === "other") return void 0;
|
|
1296
1405
|
if (isBarrel(place)) return place.unit === importer.unit ? void 0 : BARREL_MESSAGE;
|
|
1297
1406
|
if (importer.layer === "core") return isRelative && place.layer !== "core" ? escapeMessage(importer) : void 0;
|
|
1407
|
+
if (place.unit !== void 0 && importer.ancestors?.includes(place.unit) === true) return ancestorMessage(importer.unit, place.unit);
|
|
1298
1408
|
return unitDoorMessage(importer, place, isRelative);
|
|
1299
1409
|
}
|
|
1300
1410
|
/** L4: a rule imports only its siblings, the types of core and the rules of shared. */
|
|
@@ -1308,10 +1418,311 @@ const layerImports = layoutRule("A layer imports only the layers below it: core
|
|
|
1308
1418
|
ignores: [...TESTS, "**/generated/**"]
|
|
1309
1419
|
}, layerMessage);
|
|
1310
1420
|
/** Doors: a feature is imported from its index.ts, and the barrels only by game.ts. */
|
|
1311
|
-
const featureDoor = layoutRule("A feature is imported from its index.ts; the barrels only by game.ts; an import that leaves a feature is an alias.", {
|
|
1421
|
+
const featureDoor = layoutRule("A feature is imported from its index.ts at any depth; a sub-feature never imports a feature it lives in; the barrels only by game.ts; an import that leaves a feature is an alias.", {
|
|
1312
1422
|
files: ["**/{core,shared,features,plugins}/**"],
|
|
1313
1423
|
ignores: TESTS
|
|
1314
|
-
}, doorMessage);
|
|
1424
|
+
}, doorMessage, true);
|
|
1425
|
+
/**
|
|
1426
|
+
* The folder a source names beside the importing door.
|
|
1427
|
+
*
|
|
1428
|
+
* @param source - The module specifier as written.
|
|
1429
|
+
* @returns The folder for `./<folder>`, `./<folder>/index` and either with an extension; else undefined.
|
|
1430
|
+
* @example
|
|
1431
|
+
* ```ts
|
|
1432
|
+
* importedFolder("./ep01/index.ts"); // "ep01"
|
|
1433
|
+
* ```
|
|
1434
|
+
*/
|
|
1435
|
+
function importedFolder(source) {
|
|
1436
|
+
return FOLDER_SOURCE.exec(source)?.[1];
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Whether a source imports a folder beside the door: `./<folder>`, `./<folder>/index`, either with
|
|
1440
|
+
* `.ts`, `.tsx` or `.js`.
|
|
1441
|
+
*
|
|
1442
|
+
* @param source - The module specifier as written.
|
|
1443
|
+
* @param folder - The folder name.
|
|
1444
|
+
* @returns True when the source names the folder's door.
|
|
1445
|
+
* @example
|
|
1446
|
+
* ```ts
|
|
1447
|
+
* isFolderImport("./ep03/index.js", "ep03"); // true
|
|
1448
|
+
* ```
|
|
1449
|
+
*/
|
|
1450
|
+
function isFolderImport(source, folder) {
|
|
1451
|
+
return importedFolder(source) === folder;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Whether a unit brings resources of its own: a feature owner rather than a code-only sub-feature.
|
|
1455
|
+
*
|
|
1456
|
+
* @param unit - A feature unit.
|
|
1457
|
+
* @returns True with `assets/`, `assets.ts` or `strings/`.
|
|
1458
|
+
* @example
|
|
1459
|
+
* ```ts
|
|
1460
|
+
* hasResources({ name: "kit", dir: "features/a/kit", depth: 1, resources: { assets: true, strings: false } }); // true
|
|
1461
|
+
* ```
|
|
1462
|
+
*/
|
|
1463
|
+
function hasResources(unit) {
|
|
1464
|
+
return unit.resources.assets || unit.resources.strings;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Whether a folder on disk is a feature owner: a door and resources of its own.
|
|
1468
|
+
*
|
|
1469
|
+
* @param folder - The absolute folder.
|
|
1470
|
+
* @returns True with an `index.ts` or `index.tsx` and `assets/`, `assets.ts` or `strings/`.
|
|
1471
|
+
*/
|
|
1472
|
+
function isOwnerFolder(folder) {
|
|
1473
|
+
const has = (name, isFolder) => {
|
|
1474
|
+
const stat = statSync(path.join(folder, name), { throwIfNoEntry: false });
|
|
1475
|
+
return isFolder ? stat?.isDirectory() === true : stat?.isFile() === true;
|
|
1476
|
+
};
|
|
1477
|
+
return (has("index.ts", false) || has("index.tsx", false)) && (has("assets", true) || has("assets.ts", false) || has("strings", true));
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Whether a folder holds a feature owner directly: a child folder, not a kind folder, with a door
|
|
1481
|
+
* and resources.
|
|
1482
|
+
*
|
|
1483
|
+
* @param folder - The absolute folder of a unit, which exists: its door is being linted.
|
|
1484
|
+
* @returns True when one child folder is an owner.
|
|
1485
|
+
*/
|
|
1486
|
+
function hasOwnerChild(folder) {
|
|
1487
|
+
return readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !KIND_FOLDER.test(entry.name)).some((entry) => isOwnerFolder(path.join(folder, entry.name)));
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* The names the `features: [...]` arrays of a door list.
|
|
1491
|
+
*
|
|
1492
|
+
* @param lists - The arrays.
|
|
1493
|
+
* @returns The identifiers among their elements.
|
|
1494
|
+
*/
|
|
1495
|
+
function listedNames(lists) {
|
|
1496
|
+
const names = /* @__PURE__ */ new Set();
|
|
1497
|
+
for (const { elements } of lists) for (const element of elements) {
|
|
1498
|
+
const name = element?.type === "Identifier" ? nameOf(element) : void 0;
|
|
1499
|
+
if (name !== void 0) names.add(name);
|
|
1500
|
+
}
|
|
1501
|
+
return names;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* The elements of a `features: [...]` property.
|
|
1505
|
+
*
|
|
1506
|
+
* @param node - A Property.
|
|
1507
|
+
* @returns The elements, or undefined for any other property.
|
|
1508
|
+
*/
|
|
1509
|
+
function listingOf(node) {
|
|
1510
|
+
const { key } = node;
|
|
1511
|
+
const isName = key?.type === "Identifier" && !node.computed && nameOf(key) === "features";
|
|
1512
|
+
const isString = key?.type === "Literal" && key.value === "features";
|
|
1513
|
+
const value = isNode(node.value) ? node.value : void 0;
|
|
1514
|
+
return (isName || isString) && value?.type === "ArrayExpression" ? value.elements ?? [] : void 0;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Whether a callee is `defineFeature` or a member `<x>.defineFeature`.
|
|
1518
|
+
*
|
|
1519
|
+
* @param callee - The callee of a CallExpression.
|
|
1520
|
+
* @returns True for the definer.
|
|
1521
|
+
*/
|
|
1522
|
+
function isDefiner(callee) {
|
|
1523
|
+
if (callee?.type === "Identifier") return nameOf(callee) === "defineFeature";
|
|
1524
|
+
return callee?.type === "MemberExpression" && propertyName(callee) === "defineFeature";
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The checks of a parent door: every child owner is imported from `./<child>` and listed.
|
|
1528
|
+
*
|
|
1529
|
+
* @param door - The door.
|
|
1530
|
+
* @param read - What the door holds.
|
|
1531
|
+
* @param program - The Program node, for a missing import.
|
|
1532
|
+
* @returns The reports.
|
|
1533
|
+
*/
|
|
1534
|
+
function childReports(door, read, program) {
|
|
1535
|
+
const listed = listedNames(read.lists);
|
|
1536
|
+
const reports = [];
|
|
1537
|
+
for (const child of door.units) {
|
|
1538
|
+
if (child.parent !== door.unit.name || !hasResources(child)) continue;
|
|
1539
|
+
const imported = read.imports.filter((item) => isFolderImport(item.source, child.name));
|
|
1540
|
+
const [first] = imported;
|
|
1541
|
+
if (first === void 0) reports.push({
|
|
1542
|
+
node: program,
|
|
1543
|
+
message: `Sub-features: ${child.dir} is a sub-feature of ${door.unit.name}, and this door does not import it. Import it from "./${child.name}" and list it in features: [...].`
|
|
1544
|
+
});
|
|
1545
|
+
else if (!imported.some((item) => item.locals.some((name) => listed.has(name)))) {
|
|
1546
|
+
const shown = first.locals.find((name) => name.endsWith("Feature")) ?? first.locals[0] ?? "<feature>";
|
|
1547
|
+
reports.push({
|
|
1548
|
+
node: first.node,
|
|
1549
|
+
message: `Sub-features: "${first.source}" is imported but not listed in features: [...]. List the sub-feature: features: [..., ${shown}].`
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return reports;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* The checks of each `features: [...]` entry: a name imported from a sub-feature folder of the door.
|
|
1557
|
+
*
|
|
1558
|
+
* @param door - The door.
|
|
1559
|
+
* @param read - What the door holds.
|
|
1560
|
+
* @returns The reports.
|
|
1561
|
+
*/
|
|
1562
|
+
function entryReports(door, read) {
|
|
1563
|
+
const reports = [];
|
|
1564
|
+
for (const list of read.lists) for (const element of list.elements) {
|
|
1565
|
+
if (element?.type !== "Identifier") {
|
|
1566
|
+
reports.push({
|
|
1567
|
+
node: element ?? list.node,
|
|
1568
|
+
message: ENTRY_SHAPE_MESSAGE
|
|
1569
|
+
});
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
const name = nameOf(element) ?? "";
|
|
1573
|
+
const from = read.imports.find((item) => item.locals.includes(name));
|
|
1574
|
+
const folder = from === void 0 ? void 0 : importedFolder(from.source);
|
|
1575
|
+
const isSubFeature = door.units.some((unit) => unit.dir === `${door.unit.dir}/${folder}`);
|
|
1576
|
+
if (folder === void 0 || !isSubFeature) reports.push({
|
|
1577
|
+
node: element,
|
|
1578
|
+
message: `Sub-features: features: [...] lists ${name}, which is not imported from a sub-feature folder of ${door.unit.name}. A sub-feature lives in ${door.unit.dir}/<name> and is imported from "./<name>".`
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
return reports;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* The check of a sub-feature's door: it registers a feature named like its folder.
|
|
1585
|
+
*
|
|
1586
|
+
* @param door - The door.
|
|
1587
|
+
* @param read - What the door holds.
|
|
1588
|
+
* @param program - The Program node, for a door that registers none.
|
|
1589
|
+
* @returns The reports.
|
|
1590
|
+
*/
|
|
1591
|
+
function nameReports(door, read, program) {
|
|
1592
|
+
const { name } = door.unit;
|
|
1593
|
+
if (door.unit.depth === 0 || read.calls.some((call) => call.name === name)) return [];
|
|
1594
|
+
const others = read.calls.filter((call) => call.defines);
|
|
1595
|
+
if (others.length > 0) return others.map((call) => ({
|
|
1596
|
+
node: call.node,
|
|
1597
|
+
message: `Sub-features: ${door.file} registers the feature "${call.name}"; a sub-feature is named like its folder: "${name}".`
|
|
1598
|
+
}));
|
|
1599
|
+
return [{
|
|
1600
|
+
node: program,
|
|
1601
|
+
message: `Sub-features: the door of ${door.unit.dir} registers no feature named "${name}". A sub-feature is named like its folder: defineFeature("${name}", { … }).`
|
|
1602
|
+
}];
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* The check of a code-only sub-feature: it declares no sub-features, neither listed nor on disk.
|
|
1606
|
+
*
|
|
1607
|
+
* @param door - The door.
|
|
1608
|
+
* @param read - What the door holds.
|
|
1609
|
+
* @param program - The Program node.
|
|
1610
|
+
* @returns The report, or undefined for an owner or a code-only door without sub-features.
|
|
1611
|
+
*/
|
|
1612
|
+
function codeOnlyReport(door, read, program) {
|
|
1613
|
+
const { unit } = door;
|
|
1614
|
+
if (unit.depth === 0 || hasResources(unit)) return void 0;
|
|
1615
|
+
return read.lists.length > 0 || hasOwnerChild(path.join(door.root, unit.dir)) ? {
|
|
1616
|
+
node: program,
|
|
1617
|
+
message: `Sub-features: ${unit.dir} has no assets/, assets.ts or strings/, so its sub-features are invisible to the scanner and the compiler. Give it a resource of its own, or move them to ${unit.parent ?? "its parent"}.`
|
|
1618
|
+
} : void 0;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Every finding of sub-features on one door.
|
|
1622
|
+
*
|
|
1623
|
+
* @param door - The door.
|
|
1624
|
+
* @param read - What the door holds.
|
|
1625
|
+
* @param program - The Program node.
|
|
1626
|
+
* @returns The reports: the declaration, then the name.
|
|
1627
|
+
*/
|
|
1628
|
+
function doorReports(door, read, program) {
|
|
1629
|
+
const codeOnly = codeOnlyReport(door, read, program);
|
|
1630
|
+
return [...codeOnly === void 0 ? [...childReports(door, read, program), ...entryReports(door, read)] : [codeOnly], ...nameReports(door, read, program)];
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* The door the context lints: the unit whose `index.ts` or `index.tsx` the file is.
|
|
1634
|
+
*
|
|
1635
|
+
* @param context - The rule context.
|
|
1636
|
+
* @returns The door, or undefined for any other file and for a feature name clash.
|
|
1637
|
+
*/
|
|
1638
|
+
function doorOf(context) {
|
|
1639
|
+
const [given] = context.options;
|
|
1640
|
+
const root = path.resolve(context.cwd, given?.root ?? ".");
|
|
1641
|
+
const file = insideOf(root, path.resolve(context.cwd, context.filename));
|
|
1642
|
+
const units = file === void 0 ? [] : readUnits(root).owners;
|
|
1643
|
+
const unit = units.find((item) => `${item.dir}/index.ts` === file || `${item.dir}/index.tsx` === file);
|
|
1644
|
+
return unit === void 0 || file === void 0 ? void 0 : {
|
|
1645
|
+
root,
|
|
1646
|
+
unit,
|
|
1647
|
+
units,
|
|
1648
|
+
file
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* The node visitors that collect a door: value imports, `features: [...]` arrays and calls with a
|
|
1653
|
+
* literal first argument.
|
|
1654
|
+
*
|
|
1655
|
+
* @param read - Where they collect to.
|
|
1656
|
+
* @returns The visitors.
|
|
1657
|
+
*/
|
|
1658
|
+
function doorCollectors(read) {
|
|
1659
|
+
return {
|
|
1660
|
+
ImportDeclaration: (node) => {
|
|
1661
|
+
const source = specifierOf(node);
|
|
1662
|
+
const locals = (node.specifiers ?? []).map((specifier) => nameOf(specifier.local)).filter((name) => name !== void 0);
|
|
1663
|
+
if (source !== void 0 && node.importKind !== "type") read.imports.push({
|
|
1664
|
+
node,
|
|
1665
|
+
source,
|
|
1666
|
+
locals
|
|
1667
|
+
});
|
|
1668
|
+
},
|
|
1669
|
+
Property: (node) => {
|
|
1670
|
+
const elements = listingOf(node);
|
|
1671
|
+
if (elements !== void 0) read.lists.push({
|
|
1672
|
+
node,
|
|
1673
|
+
elements
|
|
1674
|
+
});
|
|
1675
|
+
},
|
|
1676
|
+
CallExpression: (node) => {
|
|
1677
|
+
const [first] = node.arguments ?? [];
|
|
1678
|
+
if (first?.type === "Literal" && typeof first.value === "string") read.calls.push({
|
|
1679
|
+
node,
|
|
1680
|
+
name: first.value,
|
|
1681
|
+
defines: isDefiner(node.callee)
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Keeps a feature's declaration and its folders equal: a door imports every sub-feature owner from
|
|
1688
|
+
* `./<folder>` and lists it in `features: [...]`, lists nothing that does not come from a
|
|
1689
|
+
* sub-feature folder, and a sub-feature's door registers a feature named like its folder. Owners
|
|
1690
|
+
* are found by `src/project/features.ts` from folders; a code-only sub-feature is one because its
|
|
1691
|
+
* parent's door lists it.
|
|
1692
|
+
*
|
|
1693
|
+
* @example
|
|
1694
|
+
* ```json
|
|
1695
|
+
* "moku-game/sub-features": ["error", { "root": "src" }]
|
|
1696
|
+
* ```
|
|
1697
|
+
*/
|
|
1698
|
+
const subFeatures = {
|
|
1699
|
+
meta: {
|
|
1700
|
+
type: "problem",
|
|
1701
|
+
docs: { description: "A feature's door imports every sub-feature owner from ./<folder> and lists it in features: [...], lists only sub-feature folders, and a sub-feature's door registers a feature named like its folder." },
|
|
1702
|
+
schema: LAYOUT_SCHEMA
|
|
1703
|
+
},
|
|
1704
|
+
/**
|
|
1705
|
+
* Visit a door of a feature unit when the rule's globs match it.
|
|
1706
|
+
*
|
|
1707
|
+
* @param context - The rule context.
|
|
1708
|
+
* @returns The node visitors, none for a file out of scope or that is no door.
|
|
1709
|
+
*/
|
|
1710
|
+
create(context) {
|
|
1711
|
+
const door = inScope(context, DOOR_FILES) ? doorOf(context) : void 0;
|
|
1712
|
+
if (door === void 0) return {};
|
|
1713
|
+
const read = {
|
|
1714
|
+
imports: [],
|
|
1715
|
+
lists: [],
|
|
1716
|
+
calls: []
|
|
1717
|
+
};
|
|
1718
|
+
return {
|
|
1719
|
+
...doorCollectors(read),
|
|
1720
|
+
"Program:exit": (program) => {
|
|
1721
|
+
for (const report of doorReports(door, read, program)) context.report(report);
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1315
1726
|
/**
|
|
1316
1727
|
* The key of a suffix table as a folder: with a trailing `/`.
|
|
1317
1728
|
*
|
|
@@ -1414,6 +1825,7 @@ const plugin = {
|
|
|
1414
1825
|
"static-keys": staticKeys,
|
|
1415
1826
|
"layer-imports": layerImports,
|
|
1416
1827
|
"feature-door": featureDoor,
|
|
1828
|
+
"sub-features": subFeatures,
|
|
1417
1829
|
"test-suffix": {
|
|
1418
1830
|
meta: {
|
|
1419
1831
|
type: "problem",
|
package/dist/project.d.mts
CHANGED
|
@@ -78,6 +78,8 @@ type Anchor = {
|
|
|
78
78
|
* index.files["nodes/count.ts"]?.state; // "ok"
|
|
79
79
|
* // A game whose tsconfig.json holds paths, such as tests/fixtures/layout-game.
|
|
80
80
|
* index.tsconfig; // "tsconfig.json"
|
|
81
|
+
* // A game with sub-features, such as tests/fixtures/episodes-game.
|
|
82
|
+
* index.features?.["ep01"]; // { dir: "features/episodes/ep01", parent: "episodes" }
|
|
81
83
|
* ```
|
|
82
84
|
*/
|
|
83
85
|
type ProjectIndex = {
|
|
@@ -103,6 +105,17 @@ type ProjectIndex = {
|
|
|
103
105
|
path: string;
|
|
104
106
|
reason: string;
|
|
105
107
|
}[];
|
|
108
|
+
/**
|
|
109
|
+
* Every feature of the game by name: its root-relative folder and, for a sub-feature, the feature it lives in.
|
|
110
|
+
* Found by `findFeatureUnits` (`src/project/features.ts`): each folder of `features/`, a folder inside a feature
|
|
111
|
+
* with a door `index.ts` and `assets/`, `assets.ts` or `strings/` of its own, and a code-only folder the feature's
|
|
112
|
+
* door imports from `./<folder>` and lists in `features: [...]`. Present in every index `openProject` builds; `{}`
|
|
113
|
+
* for a game without a features folder.
|
|
114
|
+
*/
|
|
115
|
+
features?: Record<string, {
|
|
116
|
+
dir: string;
|
|
117
|
+
parent?: string;
|
|
118
|
+
}>;
|
|
106
119
|
};
|
|
107
120
|
/**
|
|
108
121
|
* What one watch batch changed: the new revision, the files whose bytes changed, the keys that
|
|
@@ -124,7 +137,8 @@ type ProjectChange = {
|
|
|
124
137
|
from: string;
|
|
125
138
|
to: string;
|
|
126
139
|
}[]; /** Keys gone from every file. */
|
|
127
|
-
removed: string[];
|
|
140
|
+
removed: string[]; /** The feature names whose entry in `features` appeared, vanished, or changed folder or parent; sorted. Absent when none. */
|
|
141
|
+
features?: string[];
|
|
128
142
|
};
|
|
129
143
|
/**
|
|
130
144
|
* One answer of `find`: the anchor with its line read from the file now. Lines and columns are
|
package/dist/project.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as readInside, _ as declaredFunctions, a as reindexPath, b as unwrap, c as definerOfCall, d as sha1, f as definersOf, g as holeOf, h as hasHole, i as openSession, l as JSX, m as HOLE, n as createHub, o as serialize, p as collectJsx, s as parseCached, u as jsxShapes, v as propertyNameOf, y as propertyOfValue } from "./watch-
|
|
1
|
+
import { S as readInside, _ as declaredFunctions, a as reindexPath, b as unwrap, c as definerOfCall, d as sha1, f as definersOf, g as holeOf, h as hasHole, i as openSession, l as JSX, m as HOLE, n as createHub, o as serialize, p as collectJsx, s as parseCached, u as jsxShapes, v as propertyNameOf, y as propertyOfValue } from "./watch-BEdrp80i.mjs";
|
|
2
2
|
import { createBrandConsole } from "@moku-labs/common/cli";
|
|
3
3
|
//#region src/project/locate.ts
|
|
4
4
|
/** The kind of a style key. */
|