@starci/eslint-canon-fe 8.2.17 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +19 -1
  3. package/docs/size-and-state-budget.md +6 -6
  4. package/drawing.mjs +65 -28
  5. package/package.json +1 -1
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/slots.yaml +99 -43
  8. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  9. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  10. package/runtime/scripts/api/git/diff.mjs +5 -0
  11. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  12. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  13. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  14. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  15. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  16. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  17. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  18. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  19. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  20. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  21. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  22. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  23. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  24. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  25. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  26. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  27. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  29. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  30. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  31. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  32. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  33. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  34. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  35. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  36. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  37. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  38. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  39. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  40. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  41. package/size-and-state-budget.mjs +11 -8
@@ -0,0 +1,104 @@
1
+ import path from 'node:path';
2
+ import { normalizedSymbolValue } from './ast-walks.mjs';
3
+ import { unwrapExpression } from './typescript.mjs';
4
+
5
+ /** Feature composition may inject block renders into a pure view without making the view a world owner. */
6
+ export class BlockRenderAnalysis {
7
+ constructor(renderer) {
8
+ this.renderer = renderer;
9
+ this.world = renderer.world;
10
+ }
11
+
12
+ featureComposition(expression, expectedFile) {
13
+ const file = expression?.getSourceFile().fileName;
14
+ return Boolean(file && !expectedFile && this.world.insideAny(this.world.roots.features, file)
15
+ && path.basename(file) === 'index.tsx');
16
+ }
17
+
18
+ renderProp(expression, checker, expectedFile) {
19
+ return this.featureComposition(expression, expectedFile) && this.renderValue(expression, checker);
20
+ }
21
+
22
+ blockTarget(expression, checker) {
23
+ const functions = this.world.expressionFunctions(expression, checker);
24
+ return functions.length > 0 && functions.every(fn => {
25
+ const file = fn.getSourceFile().fileName;
26
+ return this.world.sourceSet.has(path.resolve(file)) && path.basename(file) === 'index.tsx'
27
+ && this.world.tierOf(this.world.roots.components, file)?.tier === 'blocks'
28
+ && this.renderer.functionHasRender(fn, checker);
29
+ });
30
+ }
31
+
32
+ callbacksRender(functions, checker, seen) {
33
+ return functions.length > 0 && functions.every(fn => {
34
+ const values = this.renderer.helpers.returnedExpressions(this.world.ts, fn);
35
+ return !this.world.functionUsesWorld(fn, checker) && values.length > 0
36
+ && values.every(value => this.renderValue(value, checker, seen));
37
+ });
38
+ }
39
+
40
+ renderValue(input, checker, seen = new Set()) {
41
+ const ts = this.world.ts;
42
+ const value = unwrapExpression(ts, input);
43
+ if (!value) return false;
44
+ if (value.kind === ts.SyntaxKind.NullKeyword || value.kind === ts.SyntaxKind.FalseKeyword) return true;
45
+ if (ts.isConditionalExpression(value)) return this.renderValue(value.whenTrue, checker, seen)
46
+ && this.renderValue(value.whenFalse, checker, seen);
47
+ if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
48
+ return this.renderValue(value.right, checker, seen);
49
+ if (ts.isArrayLiteralExpression(value)) return value.elements.every(item => this.renderValue(item, checker, seen));
50
+ if (ts.isJsxFragment(value)) return this.fragmentBlocks(value, checker, seen);
51
+ if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value)) return this.jsxBlock(value, checker, seen);
52
+ if (ts.isCallExpression(value)) return this.createdBlock(value, checker, seen) || this.mappedBlocks(value, checker, seen);
53
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) return this.callbacksRender([value], checker, seen);
54
+ return this.referencedBlocks(value, checker, seen);
55
+ }
56
+
57
+ fragmentBlocks(value, checker, seen) {
58
+ const ts = this.world.ts;
59
+ return value.children.every(child => {
60
+ if (ts.isJsxText(child)) return !child.text.trim();
61
+ return this.renderValue(ts.isJsxExpression(child) ? child.expression : child, checker, seen);
62
+ });
63
+ }
64
+
65
+ jsxBlock(value, checker, seen) {
66
+ const ts = this.world.ts;
67
+ const opening = ts.isJsxElement(value) ? value.openingElement : value;
68
+ if (!this.blockTarget(opening.tagName, checker)) return false;
69
+ const boundary = { hasBoundary: true };
70
+ return this.renderer.jsxChildrenBoundary(value, opening, checker, seen, null, false, boundary)
71
+ && this.renderer.jsxAttributesBoundary(opening, checker, seen, null, boundary);
72
+ }
73
+
74
+ mappedBlocks(value, checker, seen) {
75
+ const ts = this.world.ts;
76
+ const callee = unwrapExpression(ts, value.expression);
77
+ if (!ts.isPropertyAccessExpression(callee) && !ts.isElementAccessExpression(callee)) return false;
78
+ let name = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
79
+ if (name === null && ts.isStringLiteralLike(callee.argumentExpression)) name = callee.argumentExpression.text;
80
+ return name === 'map' && value.arguments.length > 0
81
+ && this.callbacksRender(this.world.expressionFunctions(value.arguments[0], checker), checker, seen);
82
+ }
83
+
84
+ createdBlock(value, checker, seen) {
85
+ const ts = this.world.ts;
86
+ if (!this.renderer.isCreateElement(value, checker) || !value.arguments[0]
87
+ || !this.blockTarget(value.arguments[0], checker)) return false;
88
+ const boundary = { hasBoundary: true };
89
+ const props = value.arguments[1] ? unwrapExpression(ts, value.arguments[1]) : null;
90
+ return this.renderer.createElementPropsBoundary(props, checker, seen, null, boundary, false)
91
+ && value.arguments.slice(2).every(child => this.renderer.renderBoundary(child, checker, seen));
92
+ }
93
+
94
+ referencedBlocks(value, checker, seen) {
95
+ const ts = this.world.ts;
96
+ const symbol = normalizedSymbolValue(ts, checker, this.renderer.helpers.selectedSymbol(ts, checker, value));
97
+ if (!symbol || seen.has(symbol)) return false;
98
+ const nextSeen = new Set(seen).add(symbol);
99
+ const values = (symbol.getDeclarations?.() ?? []).flatMap(declaration => ts.isVariableDeclaration(declaration)
100
+ && declaration.initializer ? [declaration.initializer] : []);
101
+ if (values.length) return values.every(item => this.renderValue(item, checker, nextSeen));
102
+ return this.callbacksRender(this.world.expressionFunctions(value, checker), checker, nextSeen);
103
+ }
104
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { normalizedSymbolValue } from './ast-walks.mjs';
3
3
  import { relativePath, unwrapExpression } from './typescript.mjs';
4
+ import { BlockRenderAnalysis } from './frontend-block-render.mjs';
4
5
  function importDeclarationFor(ts, node) {
5
6
  for (let current = node; current; current = current.parent) if (ts.isImportDeclaration(current)) return current;
6
7
  return null;
@@ -41,12 +42,14 @@ export class WorldRenderAnalysis {
41
42
  constructor(world, helpers) {
42
43
  this.world = world;
43
44
  this.helpers = helpers;
45
+ this.blocks = new BlockRenderAnalysis(this);
44
46
  }
45
47
 
46
48
  expressionHasRender(expression, checker, seen = new Set()) {
47
49
  expression = unwrapExpression(this.world.ts, expression);
48
50
  if (!expression) return false;
49
51
  if (this.world.ts.isJsxElement(expression) || this.world.ts.isJsxSelfClosingElement(expression) || this.world.ts.isJsxFragment(expression)) return true;
52
+ if (this.world.ts.isArrayLiteralExpression(expression)) return expression.elements.some(item => this.expressionHasRender(item, checker, seen));
50
53
  if (this.world.ts.isCallExpression(expression) && isReactCreateElement(this.world.ts, checker, expression)) return true;
51
54
  if (this.world.ts.isConditionalExpression(expression)) return this.conditionalExpressionHasRender(expression, checker, seen);
52
55
  const logicalRender = this.logicalExpressionHasRender(expression, checker, seen);
@@ -148,6 +151,22 @@ export class WorldRenderAnalysis {
148
151
  || this.world.expressionFunctions(expression, checker).some(fn => this.functionHasRender(fn, checker));
149
152
  }
150
153
 
154
+ isCreateElement(expression, checker) {
155
+ return isReactCreateElement(this.world.ts, checker, expression);
156
+ }
157
+
158
+ propValueBoundary(input, checker, seen, expectedFile, pureTarget) {
159
+ const ts = this.world.ts;
160
+ const value = unwrapExpression(ts, input);
161
+ if (pureTarget && this.blocks.renderProp(value, checker, expectedFile)) return true;
162
+ if (pureTarget && this.blocks.featureComposition(value, expectedFile) && ts.isObjectLiteralExpression(value)) {
163
+ return value.properties.every(property => !ts.isPropertyAssignment(property)
164
+ || this.propValueBoundary(property.initializer, checker, seen, expectedFile, pureTarget));
165
+ }
166
+ return !this.expressionSuppliesRender(value, checker) || this.pureRenderTarget(value, checker, expectedFile)
167
+ || this.renderBoundary(value, checker, seen, true, expectedFile);
168
+ }
169
+
151
170
  routedLayoutChild(opening, child, checker) {
152
171
  const ts = this.world.ts;
153
172
  if (!ts.isJsxExpression(child) || !child.expression) return false;
@@ -216,7 +235,7 @@ export class WorldRenderAnalysis {
216
235
  if (!pureTarget && !wrapper) return false;
217
236
  const boundary = { hasBoundary: pureTarget };
218
237
  const props = expression.arguments[1] ? unwrapExpression(ts, expression.arguments[1]) : null;
219
- if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary)) return false;
238
+ if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary, pureTarget)) return false;
220
239
  const children = expression.arguments.slice(2);
221
240
  if (children.length) {
222
241
  if (!children.every(child => this.renderBoundary(child, checker, seen, false, expectedFile))) return false;
@@ -225,19 +244,19 @@ export class WorldRenderAnalysis {
225
244
  return boundary.hasBoundary;
226
245
  }
227
246
 
228
- createElementPropsBoundary(props, checker, seen, expectedFile, boundary) {
247
+ createElementPropsBoundary(props, checker, seen, expectedFile, boundary, pureTarget) {
229
248
  const ts = this.world.ts;
230
249
  if (!props || props.kind === ts.SyntaxKind.NullKeyword) return true;
231
250
  if (!ts.isObjectLiteralExpression(props) || props.properties.some(property => ts.isSpreadAssignment(property))) return false;
232
- return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary));
251
+ return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary, pureTarget));
233
252
  }
234
253
 
235
- propsPropertyBoundary(property, checker, seen, expectedFile, boundary) {
254
+ propsPropertyBoundary(property, checker, seen, expectedFile, boundary, pureTarget) {
236
255
  const ts = this.world.ts;
237
256
  if (!ts.isPropertyAssignment(property)) return true;
238
257
  const value = unwrapExpression(ts, property.initializer);
258
+ if (!this.propValueBoundary(value, checker, seen, expectedFile, pureTarget)) return false;
239
259
  if (!this.expressionSuppliesRender(value, checker)) return true;
240
- if (!this.pureRenderTarget(value, checker, expectedFile) && !this.renderBoundary(value, checker, seen, true, expectedFile)) return false;
241
260
  const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
242
261
  if (['children', 'component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
243
262
  return true;
@@ -270,7 +289,7 @@ export class WorldRenderAnalysis {
270
289
  if (!pureTarget && !wrapper) return false;
271
290
  const boundary = { hasBoundary: pureTarget };
272
291
  if (!this.jsxChildrenBoundary(expression, opening, checker, seen, expectedFile, pureTarget, boundary)) return false;
273
- if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary)) return false;
292
+ if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary, pureTarget)) return false;
274
293
  return boundary.hasBoundary;
275
294
  }
276
295
 
@@ -286,13 +305,13 @@ export class WorldRenderAnalysis {
286
305
  return true;
287
306
  }
288
307
 
289
- jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary) {
308
+ jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary, pureTarget = false) {
290
309
  const ts = this.world.ts;
291
310
  return opening.attributes.properties.every(attribute => !ts.isJsxAttribute(attribute) || !attribute.initializer
292
- || this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary));
311
+ || this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary, pureTarget));
293
312
  }
294
313
 
295
- jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary) {
314
+ jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary, pureTarget) {
296
315
  const ts = this.world.ts;
297
316
  const name = attribute.name.text;
298
317
  if (['fallback', 'errorElement'].includes(name)) {
@@ -300,10 +319,10 @@ export class WorldRenderAnalysis {
300
319
  if (ts.isJsxExpression(attribute.initializer)
301
320
  && !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
302
321
  }
322
+ if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
323
+ && !this.propValueBoundary(attribute.initializer.expression, checker, seen, expectedFile, pureTarget)) return false;
303
324
  if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
304
325
  && this.expressionSuppliesRender(attribute.initializer.expression, checker)) {
305
- if (!this.pureRenderTarget(attribute.initializer.expression, checker, expectedFile)
306
- && !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
307
326
  if (['component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
308
327
  }
309
328
  return true;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { byCodeUnit } from '../../lib/list.mjs';
4
+ import { escapeRegExp } from '../../lib/regex.mjs';
4
5
 
5
6
  /**
6
7
  * R106 `i18n-keys` (FE_I18N_KEYS). The catalogs of an app (`apps/<app>/src/modules/i18n/messages/<locale>.json`, slot fe.modules.i18n) and
@@ -12,7 +13,7 @@ import { byCodeUnit } from '../../lib/list.mjs';
12
13
  * own source (both branches of a conditional key are read). A key no locale's catalog holds is FE_I18N_KEYS, once per call. A computed
13
14
  * key (a template with an interpolation, a variable) is not judged here; it counts as a read.
14
15
  * - a key nobody reads is dead: a leaf of the app's catalogs that no string in the app's or the shared packages' source can be reading.
15
- * A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard segment) of that source equals a
16
+ * A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard within its dotted segment) equals a
16
17
  * tail of its dotted path (`"title"`, `"card.title"`, `"orders.card.title"` all read `orders.card.title`); the test is deliberately loose
17
18
  * so a key handed through props or a table of keys is never reported, and only a key that appears nowhere is.
18
19
  *
@@ -39,7 +40,10 @@ const held = (leaves, full) => leaves.has(full) || [...leaves].some(leaf => leaf
39
40
 
40
41
  // A pattern that is only wildcards (`${a}`, `${a}.${b}`) names nothing: it would read every key, so it reads none.
41
42
  const tailMatches = (pattern, leafSegments) => pattern.some(segment => segment !== '*' && segment !== '') && pattern.length <= leafSegments.length
42
- && pattern.every((segment, index) => segment === '*' || segment === leafSegments[leafSegments.length - pattern.length + index]);
43
+ && pattern.every((segment, index) => {
44
+ const leaf = leafSegments[leafSegments.length - pattern.length + index];
45
+ return segment instanceof RegExp ? segment.test(leaf) : segment === '*' || segment === leaf;
46
+ });
43
47
 
44
48
  function catalogsPerApp(config, graph) {
45
49
  const catalogs = new Map();
@@ -56,12 +60,21 @@ function catalogsPerApp(config, graph) {
56
60
  return catalogs;
57
61
  }
58
62
 
63
+ /** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
64
+ function templatePatternOf(node) {
65
+ return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
66
+ if (!segment.includes('\u0000')) return segment;
67
+ const parts = segment.split('\u0000');
68
+ return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
69
+ });
70
+ }
71
+
59
72
  function patternsOf(ts, files) {
60
73
  const patterns = [];
61
74
  for (const file of files) {
62
75
  const visit = node => {
63
76
  if (ts.isStringLiteralLike(node)) patterns.push(node.text.split('.'));
64
- else if (ts.isTemplateExpression(node)) patterns.push([node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').replaceAll('\u0000', '*').split('.'));
77
+ else if (ts.isTemplateExpression(node)) patterns.push(templatePatternOf(node));
65
78
  ts.forEachChild(node, visit);
66
79
  };
67
80
  visit(file.sourceFile);
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { canonical } from './config.mjs';
3
3
  import { hasAnyFlag, sourceLocation } from '../../lib/ts-ast.mjs';
4
+ import { byCodeUnit } from '../../lib/list.mjs';
4
5
 
5
6
  const HELPER_DEPTH = 4;
6
7
 
@@ -110,6 +111,21 @@ const at = (env, rel, sourceFile, node, extra = {}) => ({ path: rel, ...sourceLo
110
111
  /** The app root file of an app (apps/<name>/src/app.module.ts), or null when the program does not hold it. */
111
112
  const appRoot = (env, name) => graphFile(env, `apps/${name}/src/app.module.ts`);
112
113
 
114
+ /**
115
+ * Whether a repository-relative file is a composition group of an app: `apps/<app>/src/compose/<area>.ts`, the module list of
116
+ * one area that the app root spreads into its imports (slot allows `compose/<area>.ts` of every be.app.* slot).
117
+ */
118
+ export const isComposeGroup = rel => /^apps\/[^/]+\/src\/compose\/[^/]+\.ts$/.test(rel) && !/\.(?:spec|d)\.ts$/.test(rel);
119
+
120
+ /** The app root and the composition groups of an app, in path order (the root first); [] when the program holds no root. */
121
+ const appCompositionFiles = (env, name) => {
122
+ const root = appRoot(env, name);
123
+ if (!root) return [];
124
+ const prefix = `apps/${name}/src/compose/`;
125
+ const groups = [...env.graph.files.values()].filter(file => file.rel.startsWith(prefix) && isComposeGroup(file.rel));
126
+ return [root, ...groups.sort((a, b) => byCodeUnit(a.rel, b.rel))];
127
+ };
128
+
113
129
  /** The body a call or a spread identifier resolves to when it is a function or a const declared in a file of the program (not a package), else null. */
114
130
  function helperBodyOf(env, checker, node) {
115
131
  const { ts } = env;
@@ -200,6 +216,7 @@ export function machineKit({ context, graph }) {
200
216
  valueOfProperty: bound(valueOfProperty),
201
217
  at: bound(at),
202
218
  appRoot: bound(appRoot),
219
+ appCompositionFiles: bound(appCompositionFiles),
203
220
  };
204
221
  }
205
222
 
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { machineKit } from './machine-ast.mjs';
2
+ import { isComposeGroup, machineKit } from './machine-ast.mjs';
3
3
  import { treeOf } from './required-files.mjs';
4
4
  import { byCodeUnit } from '../../lib/list.mjs';
5
5
  import { trimTrailingSlashes } from '../trailing-slashes.mjs';
@@ -13,8 +13,9 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
13
13
  * anywhere in a feature, and an `@Module` class (or a `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a
14
14
  * feature file that is neither the application module nor the transport module of its own folder are refused;
15
15
  * - apps import only transport modules, of the kinds their app kind allows: the `composedBy` list of each transport slot
16
- * in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root that
17
- * lists a feature's application module, or the transport module of another kind, is refused;
16
+ * in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root (app.module.ts
17
+ * or a composition group of its compose/ folder) that lists a feature's application module, or the transport module
18
+ * of another kind, is refused;
18
19
  * - a composed root (an owner slot with `composedBy`, be.cli) holds one static module per folder, named after the folder
19
20
  * (cli.module.ts, <group>/<group>.module.ts); an app of a composing kind lists the root module only, and an app of any
20
21
  * other kind lists none of its modules.
@@ -22,7 +23,6 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
22
23
  export const MODULE_PER_TRANSPORT_RULE_IDS = ['BE_MODULE_SHAPE'];
23
24
 
24
25
  const RULE = 'BE_MODULE_SHAPE';
25
- const APP_ROOT_FILE = 'app.module.ts';
26
26
 
27
27
  function featureOwnerOf(resolver, rel) {
28
28
  const owner = resolver.ownerOf(rel);
@@ -152,28 +152,37 @@ function inspectModuleDeclarations(checker, target, node, root, app, scan, seen)
152
152
  return references;
153
153
  }
154
154
 
155
- function inspectAppRoot(app, scan) {
156
- const { graph, kit, ts } = scan;
157
- const root = graph.files.get(`apps/${app.name}/src/${APP_ROOT_FILE}`);
158
- if (!root) return 0;
159
- const checker = kit.checkerOf(root.sourceFile);
160
- const seen = new Set();
155
+ /** The module references of one composition file: the root's `imports` lists, or every array of a composition group (its module list). */
156
+ function inspectCompositionFile(file, app, scan, seen) {
157
+ const { kit, ts } = scan;
158
+ const checker = kit.checkerOf(file.sourceFile);
159
+ const group = isComposeGroup(file.rel);
161
160
  let references = 0;
162
161
  const judge = node => {
163
162
  const call = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register';
164
163
  if (ts.isCallExpression(node) && !call) return;
165
164
  const target = call ? node.expression.expression : node;
166
- references += inspectModuleDeclarations(checker, target, node, root, app, scan, seen);
165
+ references += inspectModuleDeclarations(checker, target, node, file, app, scan, seen);
167
166
  };
168
- kit.walk(root.sourceFile, node => {
167
+ kit.walk(file.sourceFile, node => {
169
168
  if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
170
169
  for (const element of node.initializer.elements) judge(element);
170
+ } else if (group && ts.isArrayLiteralExpression(node)) {
171
+ for (const element of node.elements) judge(element);
171
172
  } else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') judge(node);
172
173
  return true;
173
174
  });
174
175
  return references;
175
176
  }
176
177
 
178
+ /** The module references of an app: its root `apps/<app>/src/app.module.ts` and the composition groups of `compose/` it spreads. */
179
+ function inspectAppRoot(app, scan) {
180
+ const seen = new Set();
181
+ let references = 0;
182
+ for (const file of scan.kit.appCompositionFiles(app.name)) references += inspectCompositionFile(file, app, scan, seen);
183
+ return references;
184
+ }
185
+
177
186
  function appModuleReferences(apps, scan) {
178
187
  let references = 0;
179
188
  for (const app of apps) references += inspectAppRoot(app, scan);
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { machineKit } from './machine-ast.mjs';
2
+ import { isComposeGroup, machineKit } from './machine-ast.mjs';
3
3
  import { callName } from './supabase-ast.mjs';
4
4
  import { isTestWorldSlot } from '../test-world-slot.mjs';
5
5
  import { byCodeUnit } from '../../lib/list.mjs';
@@ -7,7 +7,8 @@ import { byCodeUnit } from '../../lib/list.mjs';
7
7
  /**
8
8
  * R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
9
9
  * reference's `CodingModule imports DeviceModule` shape). Each capability has one representative module: the only module
10
- * of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`.
10
+ * of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`, or in a
11
+ * composition group `apps/<app>/src/compose/<area>.ts` the root spreads (an app root here means the root and its groups).
11
12
  * It may split itself into sub-modules imported as plain `imports: [Sub]`; a sub-module is never in an app and has one
12
13
  * importer, its parent. Over every `@Module` class of the program:
13
14
  *
@@ -57,6 +58,7 @@ function enclosingModule(ts, modules, node) {
57
58
  /** Records the reference an element of an imports list, or a register call, makes to a module. */
58
59
  function noteReference(scan, element, viaImports) {
59
60
  const { ts, kit, modules, checker, file, seen, references, isAppRoot } = scan;
61
+ if (seen.has(element)) return;
60
62
  const call = callName(ts, element) === 'register';
61
63
  if (ts.isCallExpression(element) && !call) return;
62
64
  const target = targetOf(kit, modules, checker, call ? element.expression.expression : element);
@@ -72,14 +74,19 @@ const isImportsList = (scan, node) => scan.ts.isPropertyAssignment(node) && scan
72
74
  const isStrayGlobal = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'isGlobal'
73
75
  && node.initializer.kind === scan.ts.SyntaxKind.TrueKeyword && !scan.isAppRoot(scan.file) && !scan.isWorld(scan.file);
74
76
 
77
+ // A composition group's module list is any array of the group: the root spreads it into its imports.
78
+ const isGroupList = (scan, node) => scan.ts.isArrayLiteralExpression(node) && isComposeGroup(scan.file.rel);
79
+
75
80
  function visitReferenceNode(scan, node) {
76
81
  if (isImportsList(scan, node)) {
77
82
  for (const element of node.initializer.elements) noteReference(scan, element, true);
83
+ } else if (isGroupList(scan, node)) {
84
+ for (const element of node.elements) noteReference(scan, element, true);
78
85
  } else if (callName(scan.ts, node) === 'register' && !scan.seen.has(node)) {
79
86
  noteReference(scan, node, false);
80
87
  }
81
88
  if (isStrayGlobal(scan, node)) {
82
- scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
89
+ scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts or a group of apps/<app>/src/compose/), where the representative module of a capability is registered; a module never decides its own globality.');
83
90
  }
84
91
  return true;
85
92
  }
@@ -170,11 +177,12 @@ export function checkRegisterOnce(input) {
170
177
  const { ts } = kit;
171
178
  const violations = [];
172
179
  const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
173
- const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
180
+ const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && (path.posix.basename(file.rel) === 'app.module.ts' || isComposeGroup(file.rel));
174
181
  // The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
175
182
  // `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
176
183
  const isWorld = file => isTestWorldSlot(file.slot);
177
- const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
184
+ const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`
185
+ || (isComposeGroup(file.rel) && file.rel.startsWith(`apps/${app.name}/src/compose/`)))?.name ?? null;
178
186
  const modules = moduleDeclarations(graph, kit, ts);
179
187
  const references = moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report);
180
188
  const rootReferences = references.filter(item => item.root);
@@ -4,6 +4,7 @@ import { lsFiles } from '../../api/git/ls-files.mjs';
4
4
  import { revParseQuery } from '../../api/git/rev-parse-query.mjs';
5
5
  import { gitOutputOf } from '../../lib/git.mjs';
6
6
  import { trimTrailingSlashes } from '../trailing-slashes.mjs';
7
+ import { requiredFileApplies } from '../slot-required.mjs';
7
8
 
8
9
  /**
9
10
  * HFS check 5: the files and directories the slot manifest requires (knowledge/hfs/slots.yaml `requires`,
@@ -173,7 +174,7 @@ function requirementExists(tree, target) {
173
174
  }
174
175
 
175
176
  function reportMissingRequirement(requirement, tree, report) {
176
- if (requirementExists(tree, requirement.target)) return;
177
+ if (requirementExists(tree, requirement.target) || !requiredFileApplies(requirement.slot, requirement.target, tree.files)) return;
177
178
  const rootExists = !requirement.root || tree.directories.has(requirement.root);
178
179
  if (!rootExists) {
179
180
  report(ruleFor(requirement.slot, requirement.target, false), requirement.root, requirement.slot, requirement.root, { kind: 'directory' },
@@ -0,0 +1,37 @@
1
+ import { catFile } from '../../api/git/cat-file.mjs';
2
+ import { diff } from '../../api/git/diff.mjs';
3
+ import { gitOutputOf } from '../../lib/git.mjs';
4
+
5
+ /** Eligible additions, copies, edits and rename destinations in the index, read in one blob batch. */
6
+ export function stagedConsumerSources(root, accept) {
7
+ try {
8
+ const options = { cwd: root, maxBuffer: 256 * 1024 * 1024 };
9
+ const files = gitOutputOf(diff(['--cached', '--relative', '--name-only', '--diff-filter=ACMR', '-z', '--', '.'], options))
10
+ .split('\0').filter(file => file && accept(file));
11
+ if (!files.length) return [];
12
+ // :./ keeps paths relative to this side's root even when git's top level is the parent app folder.
13
+ // -z terminates requests with NUL; output headers and blob trailers still use LF.
14
+ const bytes = gitOutputOf(catFile(['--batch', '-z'], {
15
+ ...options, encoding: null, input: files.map(file => `:./${file}\0`).join(''),
16
+ }));
17
+ if (!Buffer.isBuffer(bytes)) return [];
18
+ const sources = [];
19
+ let cursor = 0;
20
+ for (const file of files) {
21
+ const end = bytes.indexOf(10, cursor);
22
+ if (end < 0) return [];
23
+ const header = /^[0-9a-f]+ blob (\d+)$/u.exec(bytes.subarray(cursor, end).toString('utf8'));
24
+ if (!header) return []; // An index changed during the read: discard this supplement, keep disk consumers.
25
+ const size = Number(header[1]);
26
+ const start = end + 1;
27
+ const next = start + size;
28
+ if (!Number.isSafeInteger(size) || next >= bytes.length || bytes[next] !== 10) return [];
29
+ sources.push({ file, text: bytes.subarray(start, next).toString('utf8') });
30
+ cursor = next + 1;
31
+ }
32
+ return sources;
33
+ } catch {
34
+ // A non-git fixture (or an unavailable index) still uses the existing filesystem consumers.
35
+ return [];
36
+ }
37
+ }
@@ -1,5 +1,7 @@
1
1
  // supabase-ast.mjs - shared TypeScript-symbol and expression helpers for the front-end and back-end Supabase
2
2
  // architecture checks. Recognition follows imported declarations and inferred types, never local spelling alone.
3
+ import { unwrapExpression } from '../../lib/ts-analysis.mjs';
4
+
3
5
  export const SUPABASE_MODULES = new Set(['@supabase/ssr', '@supabase/supabase-js']);
4
6
 
5
7
  export const moduleNameOf = (ts, node) => {
@@ -38,12 +40,7 @@ const signatureFromSupabase = (checker, call) => {
38
40
  try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
39
41
  };
40
42
 
41
- export const unwrap = (ts, node) => {
42
- let current = node;
43
- while (current && (ts.isParenthesizedExpression(current) || ts.isAwaitExpression(current) || ts.isAsExpression(current)
44
- || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current))) current = current.expression;
45
- return current;
46
- };
43
+ export const unwrap = (ts, node) => unwrapExpression(ts, node, { awaitExpressions: true, satisfiesExpressions: false });
47
44
 
48
45
  const isSupabaseValue = (kit, checker, node) => {
49
46
  const current = unwrap(kit.ts, node);
@@ -3,6 +3,7 @@
3
3
  import path from 'node:path';
4
4
  import { machineKit } from './machine-ast.mjs';
5
5
  import { importedFrom, reportAt, unwrap } from './supabase-ast.mjs';
6
+ import { accessName } from './backend-app-files.mjs';
6
7
 
7
8
  const BE_SUPABASE_SLOT = 'be.integrations.supabase';
8
9
  const BE_JWT_VERIFIED = 'BE_SUPABASE_JWT_VERIFIED';
@@ -47,12 +48,6 @@ const importedCall = (kit, checker, call) => {
47
48
  return owner?.name === 'default' ? { module: owner.module, name: call.expression.name.text } : null;
48
49
  };
49
50
 
50
- const accessName = (ts, node) => {
51
- if (ts.isPropertyAccessExpression(node)) return node.name.text;
52
- if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
53
- return null;
54
- };
55
-
56
51
  const requestLike = (ts, node) => {
57
52
  const current = unwrap(ts, node);
58
53
  if (ts.isIdentifier(current)) return /^(?:req|request)$/iu.test(current.text);
@@ -14,8 +14,15 @@ import { byCodeUnit } from '../../lib/list.mjs';
14
14
  */
15
15
  export const TIER_RULE_IDS = ['BE_TIER_DIRECTION', 'BE_FEATURE_IMPORTS_FEATURE', 'BE_KIND_ISOLATION', 'FE_TIER_DIRECTION', 'FE_APP_ISOLATION', 'ARCH_OWNER_CYCLE'];
16
16
 
17
+ function tierDirectionText({ fromTier, toTier, mayImport, kindRestriction, fromKind, toKind }) {
18
+ if (!kindRestriction) return `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`;
19
+ const source = fromKind ?? 'no folder kind';
20
+ const target = toKind ?? 'no folder kind';
21
+ return `a ${fromTier} may import ${toTier} owners only from kinds ${kindRestriction.from.join(', ')} to kinds ${kindRestriction.to.join(', ')}; it imports ${source} -> ${target}`;
22
+ }
23
+
17
24
  const REASON_TEXT = {
18
- tierDirection: ({ fromTier, toTier, mayImport }) => `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`,
25
+ tierDirection: tierDirectionText,
19
26
  layerOrder: ({ fromLayer, toLayer }) => `a ${fromLayer} component may import only the layers after it; it imports a ${toLayer}`,
20
27
  crossApp: ({ from, to }) => `app ${from} imports app ${to}; apps never import each other, shared code is a packages/<pkg> slot`,
21
28
  };
@@ -9,6 +9,7 @@ import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule
9
9
  import { readJsonFile } from '../../lib/json.mjs';
10
10
  import { sourceLocation } from '../../lib/ts-ast.mjs';
11
11
  import { locateDeclaration } from '../slots.mjs';
12
+ export { unwrapExpression } from '../../lib/ts-analysis.mjs';
12
13
 
13
14
  const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
14
15
  const SCRIPT_TAIL = '[cm]?[jt]sx?';
@@ -399,14 +400,6 @@ export function reachableViolation(edges, firstEdge, forbidden, { follow = () =>
399
400
  /** A framework identity the checkers cannot prove statically. */
400
401
  export const UNPROVEN_FRAMEWORK = '(unproven framework identity)';
401
402
 
402
- /** The expression under parentheses, type assertions, non-null and satisfies wrappers. */
403
- export function unwrapExpression(ts, expression) {
404
- while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
405
- || ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression)
406
- || (ts.isSatisfiesExpression?.(expression) ?? false))) expression = expression.expression;
407
- return expression;
408
- }
409
-
410
403
  /* ---- the AST walks every architecture checker shares ---- */
411
404
 
412
405
  /** The names of `expected` an import or re-export statement binds (all of them for a namespace or star form). */
@@ -4,6 +4,7 @@ import { machineKit } from './machine-ast.mjs';
4
4
  import { constructorDependencies } from './constructor-deps.mjs';
5
5
  import { treeOf } from './required-files.mjs';
6
6
  import { byCodeUnit } from '../../lib/list.mjs';
7
+ import { importsOf } from '../../lib/ts-analysis.mjs';
7
8
 
8
9
  /**
9
10
  * R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
@@ -25,17 +26,6 @@ export const UNIT_SPEC_PROVIDERS_RULE_IDS = ['BE_SPEC_QUALITY'];
25
26
  const RULE = 'BE_SPEC_QUALITY';
26
27
  const SPEC_ROOT = /^(?:src|apps)\//u;
27
28
 
28
- /** local name -> {name, module} for every named import of a source file. */
29
- function importsOf(ts, sourceFile) {
30
- const imports = new Map();
31
- for (const statement of sourceFile.statements) {
32
- const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : null;
33
- if (!bindings || !ts.isNamedImports(bindings)) continue;
34
- for (const element of bindings.elements) imports.set(element.name.text, { name: (element.propertyName ?? element.name).text, module: statement.moduleSpecifier.text });
35
- }
36
- return imports;
37
- }
38
-
39
29
  /** The `Test.createTestingModule(...)` calls of a spec. */
40
30
  function testingModules(ts, sourceFile) {
41
31
  const calls = [];