@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.
- package/CHANGELOG.md +9 -0
- package/README.md +19 -1
- package/docs/size-and-state-budget.md +6 -6
- package/drawing.mjs +65 -28
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/git/diff.mjs +5 -0
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- 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)
|
|
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
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
25
|
-
const
|
|
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'
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
return
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 {
|
|
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
|
|
116
|
-
const
|
|
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
|
|
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({
|
|
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
|
}
|