@starci/eslint-canon-be 3.2.19 → 3.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 (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +46 -4
  3. package/app-composition.mjs +176 -0
  4. package/config-owner.mjs +34 -19
  5. package/feature-thin.mjs +59 -17
  6. package/index.mjs +2 -0
  7. package/lib/app-roles.mjs +48 -0
  8. package/lib/webhook-ack.mjs +87 -0
  9. package/lib/webhook-handoff.mjs +138 -0
  10. package/lib/webhook-verifiers.mjs +147 -0
  11. package/module-layering.mjs +2 -2
  12. package/module-shape.mjs +6 -5
  13. package/package.json +1 -1
  14. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  15. package/runtime/knowledge/hfs/rules.yaml +41 -13
  16. package/runtime/knowledge/hfs/slots.yaml +99 -43
  17. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  18. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  19. package/runtime/scripts/api/git/diff.mjs +5 -0
  20. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  21. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  22. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  23. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  24. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  25. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  26. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  27. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  28. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  29. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  30. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  31. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  32. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  33. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  36. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  37. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  38. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  39. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  40. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  41. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  42. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  43. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  44. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  47. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  48. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  49. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  50. package/webhooks.mjs +141 -69
@@ -0,0 +1,143 @@
1
+ /** Imported JSX components receive repeated data contracts; executable prop values remain compared. */
2
+ import { unwrapExpression as unwrap } from '../../lib/ts-analysis.mjs';
3
+
4
+ const TRANSLATION_MODULES = new Set(['next-intl', 'next-intl/server']);
5
+ const TRANSLATION_FACTORIES = new Set(['useTranslations', 'getTranslations']);
6
+ const uppercase = (name) => /^[A-Z]/.test(name);
7
+
8
+ function bindName(ts, scope, name, binding) {
9
+ if (ts.isIdentifier(name)) scope.bindings.set(name.text, binding);
10
+ else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
11
+ for (const element of name.elements) if (ts.isBindingElement(element)) bindName(ts, scope, element.name, binding);
12
+ }
13
+ }
14
+
15
+ function importBindings(ts, scope, statement) {
16
+ const clause = statement.importClause;
17
+ if (!clause || clause.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier)) return;
18
+ const module = statement.moduleSpecifier.text;
19
+ if (clause.name) scope.bindings.set(clause.name.text, { kind: 'component' });
20
+ const named = clause.namedBindings;
21
+ if (!named) return;
22
+ if (ts.isNamespaceImport(named)) scope.bindings.set(named.name.text, { kind: 'namespace', module });
23
+ else for (const element of named.elements) {
24
+ if (element.isTypeOnly) continue;
25
+ const imported = element.propertyName?.text ?? element.name.text;
26
+ const kind = TRANSLATION_MODULES.has(module) && TRANSLATION_FACTORIES.has(imported) ? 'translation-factory' : 'component';
27
+ scope.bindings.set(element.name.text, { kind });
28
+ }
29
+ }
30
+
31
+ function lexicalBindings(ts, sourceFile) {
32
+ const scopes = new WeakMap();
33
+ const root = { parent: null, bindings: new Map() };
34
+ root.functionScope = root;
35
+ const visit = (node, outer) => {
36
+ if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) bindName(ts, outer, node.name, { kind: 'local' });
37
+ const functionScope = ts.isFunctionLike(node);
38
+ const nested = functionScope || ts.isBlock(node) || ts.isCatchClause(node) || ts.isClassExpression(node)
39
+ || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node);
40
+ const scope = nested ? { parent: outer, bindings: new Map(), functionScope: functionScope ? null : outer.functionScope } : outer;
41
+ if (functionScope) scope.functionScope = scope;
42
+ scopes.set(node, scope);
43
+ if (ts.isImportDeclaration(node)) importBindings(ts, scope, node);
44
+ if (ts.isParameter(node)) bindName(ts, scope, node.name, { kind: 'local' });
45
+ if (ts.isFunctionExpression(node) && node.name) bindName(ts, scope, node.name, { kind: 'local' });
46
+ if (ts.isVariableDeclaration(node)) {
47
+ const flags = node.parent?.flags ?? ts.NodeFlags.Const;
48
+ bindName(ts, flags & ts.NodeFlags.BlockScoped ? scope : scope.functionScope, node.name,
49
+ { kind: flags & ts.NodeFlags.Const ? 'constant' : 'local', declaration: node });
50
+ }
51
+ ts.forEachChild(node, (child) => { visit(child, scope); });
52
+ };
53
+ visit(sourceFile, root);
54
+ return (identifier) => {
55
+ for (let scope = scopes.get(identifier); scope; scope = scope.parent) {
56
+ if (scope.bindings.has(identifier.text)) return scope.bindings.get(identifier.text);
57
+ }
58
+ return null;
59
+ };
60
+ }
61
+
62
+ function translationFactory(ts, node, bindingOf) {
63
+ node = unwrap(ts, node);
64
+ if (ts.isIdentifier(node)) return bindingOf(node)?.kind === 'translation-factory';
65
+ return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)
66
+ && TRANSLATION_FACTORIES.has(node.name.text) && bindingOf(node.expression)?.kind === 'namespace'
67
+ && TRANSLATION_MODULES.has(bindingOf(node.expression).module);
68
+ }
69
+
70
+ function translator(ts, node, bindingOf) {
71
+ if (!ts.isIdentifier(node)) return false;
72
+ const binding = bindingOf(node);
73
+ if (binding?.kind !== 'constant') return false;
74
+ let initializer = unwrap(ts, binding.declaration.initializer);
75
+ if (initializer && ts.isAwaitExpression(initializer)) initializer = unwrap(ts, initializer.expression);
76
+ return Boolean(initializer && ts.isCallExpression(initializer) && translationFactory(ts, initializer.expression, bindingOf));
77
+ }
78
+
79
+ function importedComponent(ts, tag, bindingOf) {
80
+ if (ts.isIdentifier(tag)) return uppercase(tag.text) && bindingOf(tag)?.kind === 'component';
81
+ if (!ts.isPropertyAccessExpression(tag) || !uppercase(tag.name.text)) return false;
82
+ let root = tag.expression;
83
+ while (ts.isPropertyAccessExpression(root)) root = root.expression;
84
+ return ts.isIdentifier(root) && bindingOf(root)?.kind === 'namespace';
85
+ }
86
+
87
+ function dataProperty(ts, property, mapping) {
88
+ if (ts.isSpreadAssignment(property)) return mapping(property.expression);
89
+ if (ts.isShorthandPropertyAssignment(property)) return !property.objectAssignmentInitializer;
90
+ if (!ts.isPropertyAssignment(property)) return false;
91
+ return (!ts.isComputedPropertyName(property.name) || mapping(property.name.expression)) && mapping(property.initializer);
92
+ }
93
+
94
+ function dataMapping(ts, expression, bindingOf) {
95
+ const node = unwrap(ts, expression);
96
+ if (!node) return false;
97
+ const mapping = (value) => dataMapping(ts, value, bindingOf);
98
+ if (ts.isIdentifier(node) || ts.isLiteralExpression(node) || node.kind === ts.SyntaxKind.TrueKeyword
99
+ || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.ThisKeyword) return true;
100
+ if (ts.isPropertyAccessExpression(node)) return mapping(node.expression);
101
+ if (ts.isElementAccessExpression(node)) return mapping(node.expression) && mapping(node.argumentExpression);
102
+ if (ts.isObjectLiteralExpression(node)) return node.properties.every((property) => dataProperty(ts, property, mapping));
103
+ if (ts.isArrayLiteralExpression(node)) return node.elements.every((element) => ts.isOmittedExpression(element)
104
+ || mapping(ts.isSpreadElement(element) ? element.expression : element));
105
+ if (ts.isTemplateExpression(node)) return node.templateSpans.every((span) => mapping(span.expression));
106
+ if (ts.isPrefixUnaryExpression(node)) return [ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken].includes(node.operator)
107
+ && ts.isNumericLiteral(node.operand);
108
+ return ts.isCallExpression(node) && translator(ts, node.expression, bindingOf) && node.arguments.every(mapping);
109
+ }
110
+
111
+ /** Only object/array values belong to the prop contract; do not descend into a call or function body. */
112
+ function nestedDataObjects(ts, expression, bindingOf, excluded) {
113
+ const node = unwrap(ts, expression);
114
+ if (!node) return;
115
+ if (ts.isObjectLiteralExpression(node)) {
116
+ if (dataMapping(ts, node, bindingOf)) { excluded.add(node); return; }
117
+ for (const property of node.properties) if (ts.isPropertyAssignment(property)) {
118
+ nestedDataObjects(ts, property.initializer, bindingOf, excluded);
119
+ }
120
+ } else if (ts.isArrayLiteralExpression(node)) {
121
+ for (const element of node.elements) if (!ts.isSpreadElement(element)) nestedDataObjects(ts, element, bindingOf, excluded);
122
+ }
123
+ }
124
+
125
+ /** Canon data prop objects qualify through an imported component binding, never its chosen name or file path. */
126
+ export function canonJsxCloneNodes(ts, sourceFile) {
127
+ const bindingOf = lexicalBindings(ts, sourceFile);
128
+ const excluded = new Set();
129
+ const visit = (node) => {
130
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && importedComponent(ts, node.tagName, bindingOf)) {
131
+ for (const attribute of node.attributes.properties) {
132
+ if (!ts.isJsxAttribute(attribute) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer)) continue;
133
+ const value = unwrap(ts, attribute.initializer.expression);
134
+ if (!value || !ts.isObjectLiteralExpression(value)) continue;
135
+ if (dataMapping(ts, value, bindingOf)) excluded.add(attribute);
136
+ else nestedDataObjects(ts, value, bindingOf, excluded);
137
+ }
138
+ }
139
+ ts.forEachChild(node, visit);
140
+ };
141
+ visit(sourceFile);
142
+ return excluded;
143
+ }
@@ -21,8 +21,12 @@
21
21
  * helper because NestFactory is allowed only in main.ts (BE_ENTRYPOINT_ONLY_IN_APPS), so what repeats there is the
22
22
  * canonical shape of section 5 of BE-CONVENTION. A door or main.ts that grows logic is refused by those enforcers, not
23
23
  * excused here; every other file of the same slots (mappers, dto, app.module.ts) stays compared.
24
+ * Canon scaffold subtrees (job fences, read/mapping methods, queue producers/definitions, CQRS wiring and data
25
+ * props of imported JSX frames) are also dropped through their imported shape markers. Each exclusion ends a
26
+ * token segment, so a window cannot join unrelated code across omitted scaffold lines. Custom bodies stay compared.
24
27
  */
25
28
  import { byCodeUnit } from '../../lib/list.mjs';
29
+ import { canonCloneNodes } from './canon-clones.mjs';
26
30
 
27
31
  export const CLONE_RULE_IDS = ['HFS_DUPLICATE_CODE'];
28
32
 
@@ -61,11 +65,13 @@ function visitToken(walk, node, inType) {
61
65
  const { ts, sourceFile, kinds, lines, types, lineStarts, literalKinds } = walk;
62
66
  const { SyntaxKind } = ts;
63
67
  if (isTriviaNode(SyntaxKind, node)) return;
68
+ if (walk.excluded?.has(node)) { walk.segment += 1; return; }
64
69
  const kind = node.kind;
65
70
  const typeScope = inType || kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration;
66
71
  kinds.push(normalizedKindOf(SyntaxKind, literalKinds, kind));
67
72
  lines.push(lineOfPosition(lineStarts, node.getStart(sourceFile)));
68
73
  types.push(typeScope ? 1 : 0);
74
+ walk.segments.push(walk.segment);
69
75
  ts.forEachChild(node, (child) => { visitToken(walk, child, typeScope); });
70
76
  }
71
77
 
@@ -73,13 +79,14 @@ function visitToken(walk, node, inType) {
73
79
  const isDroppedStatement = (SyntaxKind, statement) => statement.kind === SyntaxKind.ImportDeclaration || statement.kind === SyntaxKind.ImportEqualsDeclaration
74
80
  || (statement.kind === SyntaxKind.ExportDeclaration && statement.moduleSpecifier);
75
81
 
76
- export function tokenize(ts, sourceFile) {
82
+ export function tokenize(ts, sourceFile, { excluded = null } = {}) {
77
83
  const { SyntaxKind } = ts;
78
- const walk = { ts, sourceFile, kinds: [], lines: [], types: [], lineStarts: sourceFile.getLineStarts(), literalKinds: literalKindSet(SyntaxKind) };
84
+ const walk = { ts, sourceFile, kinds: [], lines: [], types: [], segments: [], segment: 0, excluded,
85
+ lineStarts: sourceFile.getLineStarts(), literalKinds: literalKindSet(SyntaxKind) };
79
86
  for (const statement of sourceFile.statements) {
80
87
  if (!isDroppedStatement(SyntaxKind, statement)) visitToken(walk, statement, false);
81
88
  }
82
- return { kinds: Int32Array.from(walk.kinds), lines: Int32Array.from(walk.lines), types: Uint8Array.from(walk.types) };
89
+ return { kinds: Int32Array.from(walk.kinds), lines: Int32Array.from(walk.lines), types: Uint8Array.from(walk.types), segments: Int32Array.from(walk.segments) };
83
90
  }
84
91
 
85
92
  /** The app a file belongs to, by the slot binding of the manifest; null for a file of no app. */
@@ -142,15 +149,15 @@ const addToBucket = (buckets, key, item) => {
142
149
 
143
150
  const addFileWindows = (buckets, file, fileIndex, N, T, hashing) => {
144
151
  const { pow1, pow2, B1, B2 } = hashing;
145
- const { kinds, lines } = file;
152
+ const { kinds, lines, segments } = file;
146
153
  const count = kinds.length;
147
154
  const { pre1, pre2 } = prefixHashes(kinds, B1, B2);
148
155
  let end = 0;
149
156
  for (let start = 0; start < count; start += 1) {
150
- if (start > 0 && lines[start] === lines[start - 1]) continue;
157
+ if (start > 0 && lines[start] === lines[start - 1] && segments[start] === segments[start - 1]) continue;
151
158
  const limit = lines[start] + N;
152
159
  if (end < start) end = start;
153
- while (end < count && lines[end] < limit) end += 1;
160
+ while (end < count && lines[end] < limit && segments[end] === segments[start]) end += 1;
154
161
  if (end === start || lines[end - 1] - lines[start] + 1 < N) continue;
155
162
  const length = end - start;
156
163
  if (length < T) continue;
@@ -224,7 +231,8 @@ const cloneBlocks = (entries, hits, N) => {
224
231
  group.sort((x, y) => x[0] - y[0]);
225
232
  let [start, end] = group[0];
226
233
  for (const [nextStart, nextEnd] of group.slice(1)) {
227
- if (nextStart <= end) { end = Math.max(end, nextEnd); continue; }
234
+ if (nextStart <= end && fileA.segments[nextStart] === fileA.segments[start]
235
+ && fileB.segments[nextStart + diag] === fileB.segments[start + diag]) { end = Math.max(end, nextEnd); continue; }
228
236
  appendCloneBlock(blocks, fileA, fileB, start, end, diag, N);
229
237
  start = nextStart; end = nextEnd;
230
238
  }
@@ -263,7 +271,8 @@ export function checkClones({ config, context, graph } = {}) {
263
271
  if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
264
272
  const unit = graph.unit(rel);
265
273
  if (!unit) continue;
266
- entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
274
+ const sourceFile = graph.files.get(rel).sourceFile;
275
+ entries.push({ rel, unit, ...tokenize(ts, sourceFile, { excluded: canonCloneNodes(ts, sourceFile, graph.profile) }) });
267
276
  }
268
277
  const buckets = cloneBuckets(entries, N, T);
269
278
  const blocks = cloneBlocks(entries, cloneHits(entries, buckets), N);
@@ -226,18 +226,20 @@ const passConnections = (argument, scope) => {
226
226
  const checkAppRegistrations = (config, kit, ts, byName, report) => {
227
227
  let registrations = 0;
228
228
  for (const app of config.apps) {
229
- const root = kit.appRoot(app.name);
230
- if (!root) continue;
231
- const checker = kit.checkerOf(root.sourceFile);
232
- const scope = { ts, kit, checker, byName, passed: new Map(), root, app, report };
233
- kit.walk(root.sourceFile, node => {
234
- const receiver = registerReceiver(ts, node);
235
- if (!receiver) return;
236
- const owner = kit.declarationsOf(checker, receiver).map(kit.ownerOfDeclaration).find(Boolean);
237
- if (owner?.tier !== 'platform' || owner.name !== 'database') return;
238
- registrations += 1;
239
- for (const argument of node.arguments) passConnections(argument, scope);
240
- });
229
+ // The root and its composition groups (compose/<area>.ts) are one composition: a connection is passed once across them.
230
+ const passed = new Map();
231
+ for (const root of kit.appCompositionFiles(app.name)) {
232
+ const checker = kit.checkerOf(root.sourceFile);
233
+ const scope = { ts, kit, checker, byName, passed, root, app, report };
234
+ kit.walk(root.sourceFile, node => {
235
+ const receiver = registerReceiver(ts, node);
236
+ if (!receiver) return;
237
+ const owner = kit.declarationsOf(checker, receiver).map(kit.ownerOfDeclaration).find(Boolean);
238
+ if (owner?.tier !== 'platform' || owner.name !== 'database') return;
239
+ registrations += 1;
240
+ for (const argument of node.arguments) passConnections(argument, scope);
241
+ });
242
+ }
241
243
  }
242
244
  return registrations;
243
245
  };
@@ -74,10 +74,16 @@ const nameFromIdentifier = (ctx, checker, node, depth) => {
74
74
  ? nameOfExpression(ctx, kit.checkerOf(declaration.getSourceFile()), declaration.initializer, depth + 1) : null;
75
75
  };
76
76
 
77
+ /** True when `declaration` is a property of the `<app>.env.ts` reader's own interface that sits beside the options file declaring `target`: the reader feeds the options value, so its property is the same slot. */
78
+ const feedsOptions = (declaration, target) => {
79
+ const home = declaration?.getSourceFile().fileName ?? '';
80
+ return home.endsWith('.env.ts') && path.posix.dirname(home) === path.posix.dirname(target.getSourceFile().fileName);
81
+ };
82
+
77
83
  const nameFromProperty = (ctx, checker, node, depth) => {
78
84
  const target = ctx.kit.declarationsOf(checker, node.name)[0];
79
85
  if (!target) return null;
80
- const names = new Set(assignmentsOf(ctx, node.name.text).filter(item => item.declaration === target)
86
+ const names = new Set(assignmentsOf(ctx, node.name.text).filter(item => item.declaration === target || feedsOptions(item.declaration, target))
81
87
  .map(item => nameOfExpression(ctx, item.checker, item.initializer, depth + 1)).filter(Boolean));
82
88
  return names.size === 1 ? [...names][0] : null;
83
89
  };
@@ -282,30 +288,35 @@ const composedConnection = (kit, declared, checker, inner) => {
282
288
  return value !== null && declared.has(value) ? value : null;
283
289
  };
284
290
 
285
- const collectComposedConnections = (kit, declared, checker, call, items) => {
291
+ const collectComposedConnections = (kit, declared, checker, call, file, items) => {
286
292
  for (const argument of call.arguments) {
287
293
  kit.walk(argument, inner => {
288
294
  const value = composedConnection(kit, declared, checker, inner);
289
295
  if (value === null) return true;
290
- items.push({ connection: value, node: inner });
296
+ items.push({ connection: value, node: inner, file });
291
297
  return false;
292
298
  });
293
299
  }
294
300
  };
295
301
 
296
- /** The declared connection names the composition root of `app` passes to the database module registration, as [{connection, node}] in source order. */
302
+ /**
303
+ * The declared connection names the composition of `app` (its root app.module.ts and the composition groups of compose/) passes to
304
+ * the database module registration, as [{connection, node, file}] in path then source order.
305
+ */
297
306
  export function connectionsComposedBy(kit, app) {
298
307
  const { ts } = kit;
299
- const root = kit.appRoot(app);
300
- if (!root) return { root: null, items: [] };
308
+ const files = kit.appCompositionFiles(app);
309
+ if (!files.length) return { root: null, items: [] };
301
310
  const declared = new Set(kit.resolver.repo.connections.map(connection => connection.name));
302
- const checker = kit.checkerOf(root.sourceFile);
303
311
  const items = [];
304
- kit.walk(root.sourceFile, node => {
305
- if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return;
306
- const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
307
- if (owner?.tier !== 'platform' || owner.name !== 'database') return;
308
- collectComposedConnections(kit, declared, checker, node, items);
309
- });
310
- return { root, items };
312
+ for (const file of files) {
313
+ const checker = kit.checkerOf(file.sourceFile);
314
+ kit.walk(file.sourceFile, node => {
315
+ if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return;
316
+ const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
317
+ if (owner?.tier !== 'platform' || owner.name !== 'database') return;
318
+ collectComposedConnections(kit, declared, checker, node, file, items);
319
+ });
320
+ }
321
+ return { root: files[0], items };
311
322
  }
@@ -23,8 +23,7 @@ const RUNNER_KINDS = new Set(['migrate', 'cli']);
23
23
  const compositionsOf = (kit, resolver, model, appOf) => {
24
24
  const composed = new Map(resolver.repo.apps.map(app => [app.name, { items: [], unresolved: false }]));
25
25
  for (const app of resolver.repo.apps) {
26
- const { root, items } = connectionsComposedBy(kit, app.name);
27
- for (const item of items) composed.get(app.name).items.push({ ...item, file: root });
26
+ for (const item of connectionsComposedBy(kit, app.name).items) composed.get(app.name).items.push(item);
28
27
  }
29
28
  for (const site of model.registrations) {
30
29
  const app = appOf(site.file.rel);
@@ -25,7 +25,7 @@
25
25
  * (ruleParams.be.logicRoles: `<name>.policy.spec.ts`, `<name>.client.spec.ts`, ...), a
26
26
  * unit spec of a webhook, gateway or subscription door (`.webhook.spec.ts`, `.gateway.spec.ts`, `.subscription.spec.ts`: a door has no service of its own), a
27
27
  * `*.builder.ts` under src/tests/fixtures/builders, and a file of the test world (slots be.tests.world and be.tests.world.kit, which
28
- * open and migrate the real databases of the e2e run) read from disk count as consumers, because a spec can only provide an
28
+ * open and migrate the real databases of the e2e run) read from disk or staged in the git index count as consumers, because a spec can only provide an
29
29
  * Inject*() token or a param type the entry exports, and the world wires the same capabilities the apps do. An integration
30
30
  * spec (slot be.tests.integration, `src/tests/integration/<capability>/*.integration-spec.ts`) counts as a consumer of exactly one
31
31
  * owner: the integration (slot be.integrations, `src/modules/integrations/<provider>/`) whose provider folder is its capability
@@ -38,6 +38,7 @@ import { canonical } from './config.mjs';
38
38
  import path from 'node:path';
39
39
  import { treeOf } from './required-files.mjs';
40
40
  import { globExpression } from '../../lib/glob.mjs';
41
+ import { stagedConsumerSources } from './staged-consumers.mjs';
41
42
 
42
43
  export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
43
44
 
@@ -241,10 +242,12 @@ function inIntegrationOf(graph, to, provider) {
241
242
  }
242
243
 
243
244
  /** The pseudo edges of one test consumer file: its import/export declarations that resolve to graph files it may reach. */
244
- function consumerFileEdges({ ts, options, graph, config }, rel, provider) {
245
+ function consumerFileEdges({ ts, options, graph, config }, rel, provider, indexedText) {
245
246
  const abs = path.join(config.root, ...rel.split('/'));
246
- let text;
247
- try { text = fs.readFileSync(abs, 'utf8'); } catch { return []; }
247
+ let text = indexedText;
248
+ if (text === undefined) {
249
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return []; }
250
+ }
248
251
  const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true);
249
252
  const edges = [];
250
253
  for (const statement of sourceFile.statements) {
@@ -257,17 +260,19 @@ function consumerFileEdges({ ts, options, graph, config }, rel, provider) {
257
260
  }
258
261
 
259
262
  /**
260
- * Pseudo edges (read from disk, outside the production program) to graph files: from the unit role specs, the fixture builders and
263
+ * Pseudo edges (read from disk and staged index sources, outside the production program) to graph files: from the unit role specs, the fixture builders and
261
264
  * the test world files to anything, and from an integration spec only to the integration of its own provider folder.
262
265
  */
263
266
  function testConsumerEdges({ context, graph, config }) {
264
267
  const resolution = { ts: context.ts, options: context.projects?.[0]?.options ?? {}, graph, config };
265
- const consumers = [...treeOf(config.root).files]
266
- .map(file => ({ file, any: testConsumer(graph, file) }))
267
- .map(({ file, any }) => ({ file, provider: any ? null : integrationSpecProvider(graph, file), any }))
268
+ // Live lint keeps tracked disk consumers; staged snapshots add the consumers present in the same commit.
269
+ const staged = stagedConsumerSources(config.root, file => testConsumer(graph, file) || integrationSpecProvider(graph, file) !== null);
270
+ const consumers = [...[...treeOf(config.root).files].map(file => ({ file })), ...staged]
271
+ .map(source => ({ ...source, any: testConsumer(graph, source.file) }))
272
+ .map(({ file, text, any }) => ({ file, text, provider: any ? null : integrationSpecProvider(graph, file), any }))
268
273
  .filter(({ any, provider }) => any || provider !== null)
269
274
  .sort((a, b) => a.file.localeCompare(b.file));
270
- return consumers.flatMap(({ file: rel, provider }) => consumerFileEdges(resolution, rel, provider));
275
+ return consumers.flatMap(({ file: rel, text, provider }) => consumerFileEdges(resolution, rel, provider, text));
271
276
  }
272
277
 
273
278
  const edgeIndex = edges => {
@@ -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;