@starci/eslint-canon-fe 8.2.18 → 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 +9 -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,110 @@
1
+ import path from 'node:path';
2
+ import { allowsFile } from '../allows.mjs';
3
+ import { relativePath, unwrapExpression } from './typescript.mjs';
4
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
5
+
6
+ const PROCESS_MODULES = new Set(['process', 'node:process']);
7
+ const PROCESS_GLOBAL = new Set(['process']);
8
+ const GLOBAL_HOLDERS = new Set(['globalThis', 'global']);
9
+ const REFLECT_GLOBAL = new Set(['Reflect']);
10
+
11
+ /** A static property name, including `object['env']` and `object[`env`]`. */
12
+ export function accessName(ts, node) {
13
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
14
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
15
+ return null;
16
+ }
17
+
18
+ function bindingName(ts, element) {
19
+ const name = element.propertyName ?? element.name;
20
+ if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text;
21
+ return ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression) ? name.expression.text : null;
22
+ }
23
+
24
+ /** A global name is not a same-named local variable or parameter. */
25
+ function isGlobalName(ts, checker, node, names) {
26
+ if (!ts.isIdentifier(node) || !names.has(node.text)) return false;
27
+ const declarations = checker?.getSymbolAtLocation(node)?.getDeclarations() ?? [];
28
+ return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
29
+ }
30
+
31
+ function recordProcessBindings(ts, clause, add, report) {
32
+ if (clause?.name) add(clause.name);
33
+ const bindings = clause?.namedBindings;
34
+ if (bindings && ts.isNamespaceImport(bindings)) add(bindings.name);
35
+ if (!bindings || !ts.isNamedImports(bindings)) return;
36
+ for (const element of bindings.elements) {
37
+ const name = (element.propertyName ?? element.name).text;
38
+ if (name === 'env') report(element);
39
+ else if (name === 'default') add(element.name);
40
+ }
41
+ }
42
+
43
+ function processImportSymbols(ts, checker, sourceFile, report) {
44
+ const imports = new Set();
45
+ const add = name => {
46
+ const symbol = checker.getSymbolAtLocation(name);
47
+ if (symbol) imports.add(symbol);
48
+ };
49
+ for (const statement of sourceFile.statements) {
50
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)
51
+ || !PROCESS_MODULES.has(statement.moduleSpecifier.text)) continue;
52
+ recordProcessBindings(ts, statement.importClause, add, report);
53
+ }
54
+ return imports;
55
+ }
56
+
57
+ /** Process bindings are resolved by symbol, so a nested parameter cannot masquerade as an imported process. */
58
+ function processAccess(ts, checker, sourceFile, report) {
59
+ const imports = processImportSymbols(ts, checker, sourceFile, report);
60
+ const isProcess = expression => {
61
+ const node = unwrapExpression(ts, expression);
62
+ if (!node) return false;
63
+ if (ts.isIdentifier(node)) return imports.has(checker.getSymbolAtLocation(node))
64
+ || isGlobalName(ts, checker, node, PROCESS_GLOBAL);
65
+ return accessName(ts, node) === 'process'
66
+ && isGlobalName(ts, checker, node.expression, GLOBAL_HOLDERS);
67
+ };
68
+ return node => {
69
+ if (accessName(ts, node) === 'env' && isProcess(node.expression)) report(node);
70
+ if (ts.isVariableDeclaration(node) && node.initializer && isProcess(node.initializer) && ts.isObjectBindingPattern(node.name)
71
+ && node.name.elements.some(element => bindingName(ts, element) === 'env')) report(node);
72
+ if (ts.isCallExpression(node) && accessName(ts, node.expression) === 'get'
73
+ && isGlobalName(ts, checker, node.expression.expression, REFLECT_GLOBAL)
74
+ && isProcess(node.arguments[0]) && node.arguments[1] && ts.isStringLiteralLike(node.arguments[1]) && node.arguments[1].text === 'env') report(node);
75
+ };
76
+ }
77
+
78
+ /** The declaration, rather than the caller's name, identifies EnvSource readers and configuration helpers. */
79
+ function appEnvironmentCall(config, ts, checker, call) {
80
+ const declaration = checker.getResolvedSignature(call)?.declaration;
81
+ if (!declaration) return false;
82
+ const relative = relativePath(config.root, declaration.getSourceFile().fileName);
83
+ if (ts.isMethodDeclaration(declaration) && ts.isClassDeclaration(declaration.parent) && declaration.parent.name?.text === 'EnvSource') {
84
+ return config.hfs.tierOf(relative) === 'platform' && path.posix.basename(config.hfs.ownerOf(relative)?.root ?? '') === 'config';
85
+ }
86
+ if (!ts.isFunctionDeclaration(declaration) && !ts.isArrowFunction(declaration) && !ts.isFunctionExpression(declaration)) return false;
87
+ return allowsFile(config.hfs, relative)?.entry === '<app>.env.ts'
88
+ || (path.posix.basename(relative).endsWith('.config.ts') && config.hfs.slotOf(relative) !== null);
89
+ }
90
+
91
+ /** Composition groups and options consume parsed options; only the app's matching env role and main read them. */
92
+ export function checkApplicationEnvironment(config, context, sourceFile, fileName, violations) {
93
+ const role = allowsFile(config.hfs, relativePath(config.root, fileName));
94
+ if (role?.allowed && (role.entry === 'main.ts' || role.entry === '<app>.env.ts')) return;
95
+ const { ts } = context;
96
+ const checker = context.checkerFor(fileName);
97
+ const report = node => violations.push({
98
+ ruleId: 'BE_APP_COMPOSITION_ONLY',
99
+ path: relativePath(config.root, fileName),
100
+ ...sourceLocation(sourceFile, node),
101
+ message: "Application environment reads live in main.ts or the matching <app>.env.ts. Options files and compose/<area>.ts receive the typed result of read<App>Env(env: EnvSource).",
102
+ });
103
+ const checkProcess = processAccess(ts, checker, sourceFile, report);
104
+ const visit = node => {
105
+ checkProcess(node);
106
+ if (ts.isCallExpression(node) && appEnvironmentCall(config, ts, checker, node)) report(node);
107
+ ts.forEachChild(node, visit);
108
+ };
109
+ visit(sourceFile);
110
+ }
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { isInside } from './config.mjs';
3
- import { slotAdmitsFile } from '../allows.mjs';
3
+ import { allowsFile, slotAdmitsFile } from '../allows.mjs';
4
4
  import { reachableViolation, relativePath } from './typescript.mjs';
5
+ import { checkApplicationEnvironment } from './backend-app-files.mjs';
5
6
  import { sourceLocation } from '../../lib/ts-ast.mjs';
6
7
  import { byCodeUnit } from '../../lib/list.mjs';
7
8
 
@@ -376,11 +377,23 @@ function checkApplicationComposition(config, ts, sourceFile, fileName, app, viol
376
377
  path: relativePath(config.root, fileName),
377
378
  line: 1,
378
379
  column: 1,
379
- message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
380
+ message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.env.ts, <app>.options.ts, compose/<area>.ts).`,
380
381
  });
381
382
  }
382
383
  }
383
384
 
385
+ /** The app's environment parser never depends on features, even through a type import or an internal barrel. */
386
+ function checkApplicationEnvEdges(config, context, fileName, violations) {
387
+ const role = allowsFile(config.hfs, relativePath(config.root, fileName));
388
+ if (!role?.allowed || role.entry !== '<app>.env.ts') return;
389
+ const isFeature = target => config.hfs.tierOf(relativePath(config.root, target)) === 'feature';
390
+ for (const edge of context.edges.get(fileName) ?? []) {
391
+ const featureChain = reachableViolation(context.edges, edge, isFeature);
392
+ if (featureChain) violations.push(finding(config, edge, 'BE_APP_COMPOSITION_ONLY',
393
+ 'The app environment file holds typed, fail-fast environment parsing only; it cannot depend on features, including through a type import or an internal barrel.', featureChain));
394
+ }
395
+ }
396
+
384
397
  function checkApplicationTransport(config, context, sourceFile, fileName, violations) {
385
398
  for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) {
386
399
  violations.push({
@@ -409,7 +422,11 @@ function checkBackendSource(config, context, sourceFiles, roots, sourceFile, vio
409
422
  const fromFeatures = insideAny(featureRoots, fileName);
410
423
  const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
411
424
  const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
412
- if (app) checkApplicationComposition(config, context.ts, sourceFile, fileName, app, violations);
425
+ if (app) {
426
+ checkApplicationComposition(config, context.ts, sourceFile, fileName, app, violations);
427
+ checkApplicationEnvEdges(config, context, fileName, violations);
428
+ checkApplicationEnvironment(config, context, sourceFile, fileName, violations);
429
+ }
413
430
  if (!fromModules && !fromFeatures) return;
414
431
  if (fromApplication) checkApplicationTransport(config, context, sourceFile, fileName, violations);
415
432
  if (fromApplication) checkFeatureTransportEdges(config, context, sourceFiles, featureRoots, fileName, violations);
@@ -5,30 +5,45 @@ import { machineKit } from './machine-ast.mjs';
5
5
  * R46 `background-unowned` (BE_BACKGROUND_UNOWNED, BE-CONVENTION 1.6 and 1.13). Background work is a transport that only
6
6
  * a worker or api app runs (an api app composes the message transport of its own service; a separate worker is opt-in):
7
7
  *
8
- * - every job processor (`*.processor.ts`, the jobs kind) and consumer (`*.consumer.ts`) is composed by an app of kind `worker` or
9
- * `api`: the file is reachable, through runtime imports, from the root module of some such app declared in hfs.json; one no app
10
- * reaches never runs. A schedule is a BullMQ job scheduler of a queue, so no scheduler decorator or timer is judged here: `@nestjs/schedule`
11
- * and the timers are owned by platform capabilities (R90 BE_INFRA_OWNER);
12
- * - a method whose name is one of the words the law names (`sweep`, `deliver`, `reconcile`, `retry`, alone or as the
13
- * first word of a camelCase name) in a domain, integrations or feature class is reachable from a composed processor or
14
- * consumer: from the processor or consumer files and everything they import, and from the files of the feature that holds them
15
- * (its handlers are registered with the command bus, not imported by the processor). Mechanisms of platform are exempt.
8
+ * - a processor, consumer, scheduler, saga or event handler is composed by an app of kind `worker` or `api`: its file is
9
+ * reachable, through runtime imports, from an app root declared in hfs.json. Context comes from its file role or framework
10
+ * decorators, never a method name. Infrastructure restrictions on scheduler libraries remain R90 BE_INFRA_OWNER's concern;
11
+ * - methods in those contexts, and methods they call transitively, are reachable from a composed background entry point:
12
+ * its imports and the files of its own feature (command handlers are registered with the bus). Platform mechanisms are
13
+ * exempt. Resolvers and controllers are user entry points and never background, even when called by a background method.
16
14
  *
17
- * Reachability is static (imports, re-exports and the feature of the processor); it proves a processor or consumer could call the method,
18
- * not that it does.
15
+ * Classification follows statically resolved calls; ownership follows runtime imports, re-exports and the entry point's feature.
19
16
  */
20
17
  export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
21
18
 
22
19
  const RULE = 'BE_BACKGROUND_UNOWNED';
23
- const BACKGROUND_ROLES = new Set(['processor', 'consumer']);
24
- /** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
25
- const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
20
+ const BACKGROUND_ROLES = new Set(['processor', 'consumer', 'scheduler', 'saga', 'event-handler']);
21
+ const USER_ROLES = new Set(['resolver', 'controller']);
22
+ const CLASS_DECORATORS = new Map([
23
+ ['@nestjs/bull:Processor', 'processor'], ['@nestjs/bullmq:Processor', 'processor'],
24
+ ['@nestjs/cqrs:EventsHandler', 'event-handler'],
25
+ ]);
26
+ const METHOD_DECORATORS = new Map([
27
+ ['@nestjs/cqrs:Saga', 'saga'], ['@nestjs/event-emitter:OnEvent', 'event-handler'],
28
+ ...['Cron', 'Interval', 'Timeout'].map(name => [`@nestjs/schedule:${name}`, 'scheduler']),
29
+ ]);
30
+ const USER_DECORATORS = new Map([['@nestjs/graphql:Resolver', 'resolver'], ['@nestjs/common:Controller', 'controller']]);
26
31
 
27
32
  const roleOf = rel => {
28
33
  const parts = path.posix.basename(rel).split('.');
29
- return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.has(parts.at(-2)) ? parts.at(-2) : null;
34
+ return parts.length >= 3 && parts.at(-1) === 'ts' ? parts.at(-2) : null;
35
+ };
36
+
37
+ const decoratedRole = (kit, checker, node, roles) => {
38
+ for (const decorator of kit.decorators(node)) {
39
+ const expression = decorator.expression;
40
+ const callee = kit.ts.isCallExpression(expression) ? expression.expression : expression;
41
+ const binding = kit.importBinding(checker, callee);
42
+ const role = binding ? roles.get(`${binding.module}:${binding.name}`) : null;
43
+ if (role) return role;
44
+ }
45
+ return null;
30
46
  };
31
- const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
32
47
 
33
48
  const adjacencyOf = edges => {
34
49
  const adjacency = new Map();
@@ -60,19 +75,16 @@ const appRootsOf = (apps, graph) => {
60
75
  return { workers, composed: [...workers, ...services] };
61
76
  };
62
77
 
63
- const backgroundFilesOf = graph => [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
64
-
65
78
  const reportUncomposedFiles = (background, composed, workerRoots, report) => {
66
- for (const file of background) {
67
- if (composed.has(file.rel)) continue;
68
- const role = roleOf(file.rel);
69
- report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'processor' ? 'job' : 'message'} module of its feature is imported by the root module of an app of kind worker or api.`, { role });
79
+ for (const { file, role } of background) {
80
+ if (file.tier === 'platform' || composed.has(file.rel)) continue;
81
+ report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; its module must be imported by the root module of an app of kind worker or api.`, { role });
70
82
  }
71
83
  };
72
84
 
73
85
  const rootsOfRunningFeatures = (running, files) => {
74
86
  const roots = new Set();
75
- for (const file of running) {
87
+ for (const { file } of running) {
76
88
  roots.add(file.rel);
77
89
  const owner = file.owner?.root;
78
90
  if (owner) for (const other of files.values()) if (other.owner?.root === owner) roots.add(other.rel);
@@ -80,22 +92,125 @@ const rootsOfRunningFeatures = (running, files) => {
80
92
  return roots;
81
93
  };
82
94
 
83
- const backgroundMethodsOf = (node, kit, ts) => node.members.flatMap(member => {
84
- if (!ts.isMethodDeclaration(member) || !member.name) return [];
85
- const name = kit.propertyNameText(member.name);
86
- return name && isBackgroundName(name) ? [{ name, node: member.name }] : [];
87
- });
95
+ const callableKey = (kit, node) => `${kit.graphPath(node)}:${node.getStart()}`;
96
+ const callableBody = (ts, declaration) => {
97
+ const node = declaration.initializer ?? declaration;
98
+ return ts.isFunctionLike(node) && node.body ? node : null;
99
+ };
100
+ const methodMember = (ts, node) => {
101
+ if (ts.isMethodDeclaration(node)) return node;
102
+ return ts.isPropertyDeclaration(node.parent) && node.parent.initializer === node ? node.parent : null;
103
+ };
88
104
 
89
- const checkBackgroundMethods = ({ graph, kit, ts, reachable, report }) => {
90
- let methods = 0;
91
- const reportMethod = (file, method) => {
92
- methods += 1;
93
- if (!reachable.has(file.rel)) report(file, method.node, `${method.name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no processor or consumer composed by a worker or api app can reach. Add a processor in features/jobs/<job> or a consumer in transport/message of a feature that runs it, and compose its module in a worker or api app.`, { method: method.name });
94
- };
105
+ const isUserClass = (kit, checker, node) => USER_ROLES.has(roleOf(kit.graphPath(node) ?? ''))
106
+ || Boolean(decoratedRole(kit, checker, node, USER_DECORATORS));
107
+
108
+ const seedClass = (scope, file, checker, node, role) => {
109
+ scope.background.set(file.rel, { file, role });
110
+ scope.contextFiles.add(file.rel);
111
+ for (const symbol of checker.getTypeAtLocation(node).getProperties()) {
112
+ for (const declaration of symbol.declarations ?? []) {
113
+ const body = callableBody(scope.kit.ts, declaration);
114
+ if (body) scope.inherited.push(body);
115
+ }
116
+ }
117
+ };
118
+
119
+ const classContext = (scope, file, checker, node, state) => {
120
+ const user = state.user || isUserClass(scope.kit, checker, node);
121
+ if (!state.nested && user) scope.userFiles.add(file.rel);
122
+ let classRole = null;
123
+ if (!state.nested && !user) {
124
+ const fileRole = BACKGROUND_ROLES.has(state.fileRole) ? state.fileRole : null;
125
+ classRole = decoratedRole(scope.kit, checker, node, CLASS_DECORATORS) ?? fileRole;
126
+ }
127
+ if (classRole) seedClass(scope, file, checker, node, classRole);
128
+ return { ...state, user, classRole };
129
+ };
130
+
131
+ const recordCallable = (scope, file, checker, node, state) => {
132
+ const { kit } = scope;
133
+ const member = methodMember(kit.ts, node);
134
+ const canSeed = member && !state.user && !state.nested;
135
+ const role = canSeed ? decoratedRole(kit, checker, member, METHOD_DECORATORS) ?? state.classRole : null;
136
+ const entry = { file, node, checker, user: state.user, member, name: member ? kit.propertyNameText(member.name) : null };
137
+ scope.callables.set(callableKey(kit, node), entry);
138
+ if (role) {
139
+ scope.background.set(file.rel, { file, role });
140
+ scope.contextFiles.add(file.rel);
141
+ scope.seeds.push(entry);
142
+ }
143
+ };
144
+
145
+ const indexCallables = (scope, file, checker, node, state) => {
146
+ const { ts } = scope.kit;
147
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) state = classContext(scope, file, checker, node, state);
148
+ const callable = ts.isFunctionLike(node);
149
+ if (callable && node.body) recordCallable(scope, file, checker, node, state);
150
+ const childState = { ...state, nested: state.nested || callable };
151
+ ts.forEachChild(node, child => indexCallables(scope, file, checker, child, childState));
152
+ };
153
+
154
+ /** Index callable bodies once; a resolver/controller is a barrier to background propagation. */
155
+ const backgroundContextOf = (graph, kit) => {
156
+ const callables = new Map();
157
+ const background = new Map();
158
+ const seeds = [];
159
+ const inherited = [];
160
+ const scope = { kit, callables, background, seeds, inherited, userFiles: new Set(), contextFiles: new Set() };
95
161
  for (const file of graph.files.values()) {
96
- if (file.tier === 'platform') continue;
97
- kit.walk(file.sourceFile, node => {
98
- if (ts.isClassDeclaration(node)) for (const method of backgroundMethodsOf(node, kit, ts)) reportMethod(file, method);
162
+ const checker = kit.checkerOf(file.sourceFile);
163
+ const fileRole = roleOf(file.rel);
164
+ if (BACKGROUND_ROLES.has(fileRole)) background.set(file.rel, { file, role: fileRole });
165
+ indexCallables(scope, file, checker, file.sourceFile, { fileRole, classRole: null, user: USER_ROLES.has(fileRole), nested: false });
166
+ }
167
+ // Request context overrides a file suffix; mixed files can still hold a separate background class.
168
+ for (const rel of scope.userFiles) if (!scope.contextFiles.has(rel)) background.delete(rel);
169
+ for (const body of inherited) {
170
+ const entry = callables.get(callableKey(kit, body));
171
+ if (entry) seeds.push(entry);
172
+ }
173
+ return { callables, background: [...background.values()], seeds };
174
+ };
175
+
176
+ const calledBodies = (kit, checker, call) => {
177
+ const { ts } = kit;
178
+ const expression = call.expression;
179
+ if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) {
180
+ const receiver = checker.getNonNullableType(checker.getTypeAtLocation(expression.expression));
181
+ if (receiver.getSymbol()?.declarations?.some(declaration => isUserClass(kit, checker, declaration))) return [];
182
+ }
183
+ const target = ts.isPropertyAccessExpression(expression) ? expression.name : expression;
184
+ const declarations = [...kit.declarationsOf(checker, target)];
185
+ const signature = checker.getResolvedSignature(call)?.declaration;
186
+ if (signature) declarations.push(signature);
187
+ return declarations.map(declaration => callableBody(ts, declaration)).filter(Boolean);
188
+ };
189
+
190
+ const checkBackgroundMethods = ({ kit, context, reachable, report }) => {
191
+ const { callables, seeds } = context;
192
+ const seen = new Set();
193
+ const queue = [...seeds];
194
+ let methods = 0;
195
+ while (queue.length) {
196
+ const entry = queue.pop();
197
+ const { file, node, checker, user, member, name } = entry;
198
+ const key = callableKey(kit, node);
199
+ if (user || seen.has(key)) continue;
200
+ seen.add(key);
201
+ if (name && file.tier !== 'platform') {
202
+ methods += 1;
203
+ if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work that no background entry point composed by a worker or api app can reach. Compose the processor, consumer, scheduler, saga or event handler that runs it in a worker or api app.`, { method: name });
204
+ }
205
+ kit.walk(node.body, inner => {
206
+ // Nested bodies run only through a resolved call or when passed as a callback.
207
+ if (kit.ts.isFunctionLike(inner) || kit.ts.isClassDeclaration(inner) || kit.ts.isClassExpression(inner)) return false;
208
+ if (!kit.ts.isCallExpression(inner)) return;
209
+ const callbacks = inner.arguments.filter(argument => kit.ts.isArrowFunction(argument) || kit.ts.isFunctionExpression(argument));
210
+ for (const body of [...calledBodies(kit, checker, inner), ...callbacks]) {
211
+ const called = callables.get(callableKey(kit, body));
212
+ if (called) queue.push(called);
213
+ }
99
214
  });
100
215
  }
101
216
  return methods;
@@ -104,7 +219,7 @@ const checkBackgroundMethods = ({ graph, kit, ts, reachable, report }) => {
104
219
  export function checkBackgroundUnowned(input) {
105
220
  const { graph } = input;
106
221
  const kit = machineKit(input);
107
- const { ts, resolver } = kit;
222
+ const { resolver } = kit;
108
223
  const violations = [];
109
224
  const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
110
225
 
@@ -112,13 +227,14 @@ export function checkBackgroundUnowned(input) {
112
227
  const closure = starts => closureOf(adjacency, starts);
113
228
  const { workers: workerRoots, composed: appRoots } = appRootsOf(resolver.repo.apps, graph);
114
229
  const composed = closure(appRoots);
115
- const background = backgroundFilesOf(graph);
116
- const running = background.filter(file => composed.has(file.rel));
230
+ const context = backgroundContextOf(graph, kit);
231
+ const { background } = context;
232
+ const running = background.filter(({ file }) => composed.has(file.rel));
117
233
  reportUncomposedFiles(background, composed, workerRoots, report);
118
234
 
119
- // Everything a composed processor or consumer can reach: its imports, and the files of its own feature (command handlers).
235
+ // Everything a composed background entry point can reach: its imports and its own feature's command handlers.
120
236
  const roots = rootsOfRunningFeatures(running, graph.files);
121
237
  const reachable = closure([...roots]);
122
- const methods = checkBackgroundMethods({ graph, kit, ts, reachable, report });
238
+ const methods = checkBackgroundMethods({ kit, context, reachable, report });
123
239
  return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
124
240
  }