@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.
Files changed (56) hide show
  1. package/README.md +13 -12
  2. package/dist/app/page.d.mts +8 -3
  3. package/dist/app/page.mjs +57 -12
  4. package/dist/app/system.d.mts +5 -3
  5. package/dist/app/system.mjs +2 -0
  6. package/dist/app.d.mts +15 -7
  7. package/dist/app.mjs +131 -27
  8. package/dist/{assets-BrKSg1O6.mjs → assets-CEMfBogh.mjs} +471 -120
  9. package/dist/assets.d.mts +30 -13
  10. package/dist/assets.mjs +1 -1
  11. package/dist/cli.d.mts +105 -8
  12. package/dist/cli.mjs +1036 -128
  13. package/dist/{commands-BaCx2B9j.d.mts → commands-DAETmK86.d.mts} +43 -7
  14. package/dist/{config-CdARrrhU.mjs → config-B87aFTW-.mjs} +25 -4
  15. package/dist/control.d.mts +130 -17
  16. package/dist/control.mjs +4 -4
  17. package/dist/{define-Ce8SVEN-.d.mts → define-BASd9ZUh.d.mts} +32 -4
  18. package/dist/dev-DbHgx-R-.mjs +89 -0
  19. package/dist/{fake-MpzwZhIZ.mjs → fake-6mazmHK3.mjs} +20 -2
  20. package/dist/features-DowEilZm.mjs +266 -0
  21. package/dist/{headless-CMHNsKjW.d.mts → headless-cFveciuY.d.mts} +1 -1
  22. package/dist/hot.d.mts +4 -2
  23. package/dist/hot.mjs +7 -4
  24. package/dist/{index-CLQ8NOTw.d.mts → index-DedbED3T.d.mts} +317 -69
  25. package/dist/index.d.mts +5 -6
  26. package/dist/index.mjs +3 -3
  27. package/dist/inspect.d.mts +12 -7
  28. package/dist/inspect.mjs +2 -2
  29. package/dist/jsx-dev-runtime.d.mts +1 -1
  30. package/dist/jsx-runtime.d.mts +1 -1
  31. package/dist/lint.d.mts +8 -3
  32. package/dist/lint.mjs +432 -20
  33. package/dist/project.d.mts +15 -1
  34. package/dist/project.mjs +1 -1
  35. package/dist/{run-Bf2hKyjZ.mjs → run-BbipFRc6.mjs} +250 -51
  36. package/dist/{runtime-BCnKn0xf.d.mts → runtime-DoSlRx8X.d.mts} +1 -1
  37. package/dist/{session-DgukhR_7.mjs → session-CeKgO2hy.mjs} +30 -2
  38. package/dist/{sources-Duzj6lhV.mjs → sources-Cdvb0rOS.mjs} +33 -6
  39. package/dist/{src-CtC8w3_z.mjs → src-BEbJ_sSx.mjs} +35536 -17789
  40. package/dist/testing.d.mts +23 -13
  41. package/dist/testing.mjs +10 -6
  42. package/dist/{types-DOlpeuGx.d.mts → types-7GXcXxxv.d.mts} +174 -242
  43. package/dist/{types-BjTtGSFb.d.mts → types-BGcda5Le.d.mts} +4459 -732
  44. package/dist/{types-gisvSHoC.d.mts → types-C73hbmxi.d.mts} +129 -17
  45. package/dist/{types-CAPv9l29.d.mts → types-DngFQJro.d.mts} +43 -5
  46. package/dist/{types-Y_rCuXtj.d.mts → types-GHE4J5Uf.d.mts} +1025 -191
  47. package/dist/{visual-dMW8iqAE.mjs → visual-2m4wJL_z.mjs} +111 -7
  48. package/dist/visual-_XhoGeIC.d.mts +345 -0
  49. package/dist/visual.d.mts +1 -332
  50. package/dist/visual.mjs +130 -20
  51. package/dist/{watch-CWI_on4T.mjs → watch-BEdrp80i.mjs} +149 -30
  52. package/llms.txt +99 -48
  53. package/package.json +8 -6
  54. package/dist/dev-Cc50Dk7H.mjs +0 -47
  55. package/dist/memory-pUQ7YZWC.d.mts +0 -50
  56. package/dist/types-BX4Ge_j5.d.mts +0 -656
package/dist/lint.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { readFileSync, statSync } from "node:fs";
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` and `node:fs` are imported; the
12
- * file is erasable TypeScript, so Node loads it unbuilt.
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: placeOf(root, file),
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 unit does
1282
- * not import its own door, another feature is reached through its door, and an import that
1283
- * leaves the unit is written as an alias.
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",
@@ -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-CWI_on4T.mjs";
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. */