@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.
- package/CHANGELOG.md +11 -0
- package/README.md +46 -4
- package/app-composition.mjs +176 -0
- package/config-owner.mjs +34 -19
- package/feature-thin.mjs +59 -17
- package/index.mjs +2 -0
- package/lib/app-roles.mjs +48 -0
- package/lib/webhook-ack.mjs +87 -0
- package/lib/webhook-handoff.mjs +138 -0
- package/lib/webhook-verifiers.mjs +147 -0
- package/module-layering.mjs +2 -2
- package/module-shape.mjs +6 -5
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- 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/webhooks.mjs +141 -69
|
@@ -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
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repeated shapes taught by the BE scaffold are wiring, not copied decisions. Match their imported
|
|
3
|
+
* ports and their small bodies, never a filename suffix or an entire decorated class: a custom method
|
|
4
|
+
* or an extra statement still goes through the clone check.
|
|
5
|
+
*/
|
|
6
|
+
import { nameText } from '../../lib/ts-ast.mjs';
|
|
7
|
+
import { importsOf, unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
8
|
+
import { nodeDecorators } from './ast-walks.mjs';
|
|
9
|
+
import { canonJsxCloneNodes } from './canon-jsx-clones.mjs';
|
|
10
|
+
|
|
11
|
+
const READ_METHODS = new Set(['find', 'findBy', 'findAndCount', 'findOne', 'findOneBy', 'findOneOrFail', 'findOneByOrFail']);
|
|
12
|
+
|
|
13
|
+
const unwrap = (ts, node) => unwrapExpression(ts, node, { awaitExpressions: true, typeAssertions: false });
|
|
14
|
+
|
|
15
|
+
const imported = (env, node, name, source) => {
|
|
16
|
+
if (!node || !env.ts.isIdentifier(node)) return false;
|
|
17
|
+
const binding = env.imports.get(node.text);
|
|
18
|
+
return binding?.name === name && binding.source === source;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const decoratorsHave = (env, node, name, source) => nodeDecorators(env.ts, node)
|
|
22
|
+
.some(decorator => imported(env, env.ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression, name, source));
|
|
23
|
+
|
|
24
|
+
const portType = (env, node, name, source) => Boolean(node && env.ts.isTypeReferenceNode(node) && imported(env, node.typeName, name, source));
|
|
25
|
+
|
|
26
|
+
/** Data moves between shapes here; operators, arbitrary calls and executable callbacks remain compared. */
|
|
27
|
+
function mapping(env, input, allowCall = () => false) {
|
|
28
|
+
const { ts } = env;
|
|
29
|
+
const node = unwrap(ts, input);
|
|
30
|
+
if (!node) return false;
|
|
31
|
+
if (ts.isIdentifier(node) || ts.isLiteralExpression(node) || [ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.ThisKeyword].includes(node.kind)) return true;
|
|
32
|
+
if (ts.isPropertyAccessExpression(node)) return mapping(env, node.expression, allowCall);
|
|
33
|
+
if (ts.isObjectLiteralExpression(node)) return node.properties.every(property => {
|
|
34
|
+
if (ts.isShorthandPropertyAssignment(property)) return !property.objectAssignmentInitializer;
|
|
35
|
+
if (ts.isSpreadAssignment(property)) return mapping(env, property.expression, allowCall);
|
|
36
|
+
return ts.isPropertyAssignment(property) && nameText(ts, property.name) !== null && mapping(env, property.initializer, allowCall);
|
|
37
|
+
});
|
|
38
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements.every(item => mapping(env, ts.isSpreadElement(item) ? item.expression : item, allowCall));
|
|
39
|
+
return ts.isCallExpression(node) && allowCall(node);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parameterNames = (ts, method) => new Set(method.parameters.filter(parameter => ts.isIdentifier(parameter.name)).map(parameter => parameter.name.text));
|
|
43
|
+
const propertyParameters = (ts, constructor) => new Set((constructor?.parameters ?? []).filter(parameter => parameter.modifiers?.length && ts.isIdentifier(parameter.name)).map(parameter => parameter.name.text));
|
|
44
|
+
const inertParameters = (ts, constructor) => constructor.parameters.every(parameter => ts.isIdentifier(parameter.name) && !parameter.initializer);
|
|
45
|
+
|
|
46
|
+
function receiverMember(ts, call) {
|
|
47
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return null;
|
|
48
|
+
const receiver = call.expression.expression;
|
|
49
|
+
return ts.isPropertyAccessExpression(receiver) && receiver.expression.kind === ts.SyntaxKind.ThisKeyword ? receiver.name.text : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const statementCall = (ts, statement) => (ts.isReturnStatement(statement) || ts.isExpressionStatement(statement)) ? unwrap(ts, statement.expression) : null;
|
|
53
|
+
|
|
54
|
+
function delegation(env, method, statement, members, allowCall = () => false) {
|
|
55
|
+
const call = statementCall(env.ts, statement);
|
|
56
|
+
return Boolean(call && env.ts.isCallExpression(call) && members.has(receiverMember(env.ts, call))
|
|
57
|
+
&& call.arguments.every(argument => mapping(env, argument, allowCall)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function canonicalConstructor(env, constructor, logger = false) {
|
|
61
|
+
if (!constructor?.body || !inertParameters(env.ts, constructor)) return false;
|
|
62
|
+
if (!logger) return constructor.body.statements.length === 0;
|
|
63
|
+
const parameters = constructor.parameters.filter(parameter => decoratorsHave(env, parameter, 'InjectLogger', '@modules/platform/logging'));
|
|
64
|
+
if (parameters.length !== 1 || constructor.body.statements.length !== 1) return false;
|
|
65
|
+
const call = statementCall(env.ts, constructor.body.statements[0]);
|
|
66
|
+
return Boolean(call && env.ts.isCallExpression(call) && call.expression.kind === env.ts.SyntaxKind.SuperKeyword
|
|
67
|
+
&& call.arguments.length === 1 && env.ts.isIdentifier(call.arguments[0]) && call.arguments[0].text === parameters[0].name.text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasHeritage(env, declaration, name, source, kind) {
|
|
71
|
+
return declaration.heritageClauses?.some(clause => clause.token === kind && clause.types.some(type => imported(env, type.expression, name, source))) ?? false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fenceStatement(env, statement, claims, job) {
|
|
75
|
+
const call = statementCall(env.ts, statement);
|
|
76
|
+
if (!call || !env.ts.isCallExpression(call) || receiverMember(env.ts, call) !== claims || call.expression.name.text !== 'advance' || call.arguments.length !== 1) return false;
|
|
77
|
+
const options = unwrap(env.ts, call.arguments[0]);
|
|
78
|
+
if (!env.ts.isObjectLiteralExpression(options) || options.properties.length !== 3) return false;
|
|
79
|
+
const properties = new Map(options.properties.filter(env.ts.isPropertyAssignment).map(property => [nameText(env.ts, property.name), property.initializer]));
|
|
80
|
+
const jobField = (node, field) => env.ts.isPropertyAccessExpression(node) && env.ts.isIdentifier(node.expression) && node.expression.text === job && node.name.text === field;
|
|
81
|
+
return jobField(properties.get('jobId') ?? {}, 'jobId') && jobField(properties.get('expectedFencingToken') ?? {}, 'fencingToken')
|
|
82
|
+
&& mapping(env, properties.get('step'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function jobMethods(env, declaration, constructor, excluded) {
|
|
86
|
+
const claims = constructor?.parameters.find(parameter => portType(env, parameter.type, 'JobClaims', '@modules/platform/jobs')
|
|
87
|
+
&& decoratorsHave(env, parameter, 'InjectJobClaims', '@modules/platform/jobs'));
|
|
88
|
+
if (!claims || !env.ts.isIdentifier(claims.name)) return;
|
|
89
|
+
const members = propertyParameters(env.ts, constructor);
|
|
90
|
+
const runKey = call => receiverMember(env.ts, call) === claims.name.text && call.expression.name.text === 'runKey'
|
|
91
|
+
&& call.arguments.length === 2 && call.arguments.every(argument => mapping(env, argument));
|
|
92
|
+
for (const method of declaration.members) {
|
|
93
|
+
if (!env.ts.isMethodDeclaration(method) || nameText(env.ts, method.name) !== 'run' || !method.body || method.parameters.length !== 1
|
|
94
|
+
|| !portType(env, method.parameters[0].type, 'ClaimedJob', '@modules/platform/jobs')) continue;
|
|
95
|
+
const statements = method.body.statements;
|
|
96
|
+
const job = [...parameterNames(env.ts, method)][0];
|
|
97
|
+
if (!statements.length || !fenceStatement(env, statements[0], claims.name.text, job)) continue;
|
|
98
|
+
if (statements.length === 1 || (statements.length === 2 && delegation(env, method, statements[1], members, runKey))) excluded.add(method);
|
|
99
|
+
else excluded.add(statements[0]);
|
|
100
|
+
}
|
|
101
|
+
if (canonicalConstructor(env, constructor)) excluded.add(constructor);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function queueMethods(env, declaration, constructor, excluded) {
|
|
105
|
+
const outbox = constructor?.parameters.find(parameter => portType(env, parameter.type, 'QueueOutbox', '@modules/platform/queue')
|
|
106
|
+
&& decoratorsHave(env, parameter, 'InjectQueueOutbox', '@modules/platform/queue'));
|
|
107
|
+
if (!outbox || !env.ts.isIdentifier(outbox.name)) return;
|
|
108
|
+
for (const method of declaration.members) {
|
|
109
|
+
if (!env.ts.isMethodDeclaration(method) || !method.body || method.body.statements.length !== 1) continue;
|
|
110
|
+
const call = statementCall(env.ts, method.body.statements[0]);
|
|
111
|
+
if (call && env.ts.isCallExpression(call) && receiverMember(env.ts, call) === outbox.name.text && call.expression.name.text === 'write'
|
|
112
|
+
&& call.arguments.length === 3 && call.arguments.every(argument => mapping(env, argument))) excluded.add(method);
|
|
113
|
+
}
|
|
114
|
+
if (canonicalConstructor(env, constructor)) excluded.add(constructor);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rowMapper(env, call, row) {
|
|
118
|
+
const { ts } = env;
|
|
119
|
+
if (!call) return false;
|
|
120
|
+
if (ts.isObjectLiteralExpression(unwrap(ts, call))) return mapping(env, call, iso => ts.isPropertyAccessExpression(iso.expression)
|
|
121
|
+
&& iso.expression.name.text === 'toISOString' && iso.arguments.length === 0 && mapping(env, iso.expression.expression));
|
|
122
|
+
if (!ts.isCallExpression(call) || call.arguments.length !== 1 || !ts.isIdentifier(call.arguments[0]) || call.arguments[0].text !== row || !ts.isIdentifier(call.expression)) return false;
|
|
123
|
+
const binding = env.imports.get(call.expression.text);
|
|
124
|
+
return Boolean(binding && /\.rows(?:\.[cm]?ts)?$/.test(binding.source)) || env.mappers.has(call.expression.text);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readResult(env, expression, row) {
|
|
128
|
+
const { ts } = env;
|
|
129
|
+
const node = unwrap(ts, expression);
|
|
130
|
+
if (!node) return false;
|
|
131
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'map'
|
|
132
|
+
&& ts.isIdentifier(node.expression.expression) && node.expression.expression.text === row && node.arguments.length === 1) {
|
|
133
|
+
const mapper = unwrap(ts, node.arguments[0]);
|
|
134
|
+
if (ts.isIdentifier(mapper)) return env.mappers.has(mapper.text) || /\.rows(?:\.[cm]?ts)?$/.test(env.imports.get(mapper.text)?.source ?? '');
|
|
135
|
+
return ts.isArrowFunction(mapper) && mapper.parameters.length === 1 && !ts.isBlock(mapper.body) && rowMapper(env, unwrap(ts, mapper.body), mapper.parameters[0].name.text);
|
|
136
|
+
}
|
|
137
|
+
if (!ts.isConditionalExpression(node)) return rowMapper(env, node, row);
|
|
138
|
+
const condition = unwrap(ts, node.condition);
|
|
139
|
+
const simpleNull = ts.isBinaryExpression(condition) && [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(condition.operatorToken.kind)
|
|
140
|
+
&& ts.isIdentifier(condition.left) && condition.left.text === row && condition.right.kind === ts.SyntaxKind.NullKeyword;
|
|
141
|
+
if (!(simpleNull || (ts.isIdentifier(condition) && condition.text === row))) return false;
|
|
142
|
+
return [node.whenTrue, node.whenFalse].every(branch => unwrap(ts, branch).kind === ts.SyntaxKind.NullKeyword || rowMapper(env, unwrap(ts, branch), row));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readCall(env, call, managers) {
|
|
146
|
+
const { ts } = env;
|
|
147
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression) || !managers.has(receiverMember(ts, call))) return false;
|
|
148
|
+
if (READ_METHODS.has(call.expression.name.text)) return call.arguments.length <= 2 && call.arguments.every(argument => mapping(env, argument));
|
|
149
|
+
const sql = call.arguments[0];
|
|
150
|
+
return call.expression.name.text === 'query' && sql && ts.isIdentifier(sql) && /^(?:FIND|LIST)_/.test(sql.text)
|
|
151
|
+
&& /\.sql(?:\.[cm]?ts)?$/.test(env.imports.get(sql.text)?.source ?? '') && call.arguments.every(argument => mapping(env, argument));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ownedRead(env, statement) {
|
|
155
|
+
const { ts } = env;
|
|
156
|
+
const call = statementCall(ts, statement);
|
|
157
|
+
if (!call || !ts.isCallExpression(call) || !imported(env, call.expression, 'requireOwnedRow', '@modules/platform/database') || call.arguments.length !== 5) return false;
|
|
158
|
+
if (!call.arguments.slice(0, 4).every(argument => mapping(env, argument))) return false;
|
|
159
|
+
const callback = unwrap(ts, call.arguments[4]);
|
|
160
|
+
if (!ts.isArrowFunction(callback) || callback.parameters.length || !ts.isBlock(callback.body) || callback.body.statements.length !== 1) return false;
|
|
161
|
+
const thrown = callback.body.statements[0];
|
|
162
|
+
if (!ts.isThrowStatement(thrown) || !thrown.expression || !ts.isNewExpression(thrown.expression) || thrown.expression.arguments?.length !== 1) return false;
|
|
163
|
+
const options = thrown.expression.arguments[0];
|
|
164
|
+
if (!ts.isObjectLiteralExpression(options) || !mapping(env, options)) return false;
|
|
165
|
+
const code = options.properties.find(property => ts.isPropertyAssignment(property) && nameText(ts, property.name) === 'code');
|
|
166
|
+
return Boolean(code && ts.isPropertyAccessExpression(code.initializer) && code.initializer.name.text === 'NotFound');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readMethods(env, declaration, constructor, excluded) {
|
|
170
|
+
const managers = new Set((constructor?.parameters ?? []).filter(parameter => portType(env, parameter.type, 'EntityManager', 'typeorm')
|
|
171
|
+
&& nodeDecorators(env.ts, parameter).some(decorator => {
|
|
172
|
+
const call = env.ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
|
|
173
|
+
const binding = env.ts.isIdentifier(call) ? env.imports.get(call.text) : null;
|
|
174
|
+
return binding?.source === '@modules/platform/database' && /^Inject.*EntityManager$/.test(binding.name);
|
|
175
|
+
})).map(parameter => parameter.name.text));
|
|
176
|
+
for (const method of declaration.members) {
|
|
177
|
+
if (!env.ts.isMethodDeclaration(method) || !method.body) continue;
|
|
178
|
+
const statements = method.body.statements;
|
|
179
|
+
if (statements.length === 1 && ownedRead(env, statements[0])) { excluded.add(method); continue; }
|
|
180
|
+
if (statements.length !== 2 || !env.ts.isVariableStatement(statements[0]) || !env.ts.isReturnStatement(statements[1])) continue;
|
|
181
|
+
const variables = statements[0].declarationList.declarations;
|
|
182
|
+
if (variables.length !== 1 || !env.ts.isIdentifier(variables[0].name) || !variables[0].initializer) continue;
|
|
183
|
+
const call = unwrap(env.ts, variables[0].initializer);
|
|
184
|
+
if (readCall(env, call, managers) && readResult(env, statements[1].expression, variables[0].name.text)) excluded.add(method);
|
|
185
|
+
}
|
|
186
|
+
if (managers.size && canonicalConstructor(env, constructor)) excluded.add(constructor);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function queueDefinition(env, node) {
|
|
190
|
+
const { ts } = env;
|
|
191
|
+
if (!env.topLevelVariables.has(node) || !node.initializer) return false;
|
|
192
|
+
const value = unwrap(ts, node.initializer);
|
|
193
|
+
if (ts.isCallExpression(value) && imported(env, value.expression, 'defineQueue', '@modules/platform/queue'))
|
|
194
|
+
return value.arguments.length === 1 && ts.isObjectLiteralExpression(unwrap(ts, value.arguments[0])) && mapping(env, value.arguments[0]);
|
|
195
|
+
if (!ts.isArrowFunction(value) || !portType(env, value.type, 'QueueSchedulerDefinition', '@modules/platform/queue')) return false;
|
|
196
|
+
const body = ts.isBlock(value.body) && value.body.statements.length === 1 && ts.isReturnStatement(value.body.statements[0]) ? value.body.statements[0].expression : value.body;
|
|
197
|
+
const object = unwrap(ts, body);
|
|
198
|
+
return Boolean(object && ts.isObjectLiteralExpression(object) && mapping(env, object));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function canonEnvironment(ts, sourceFile) {
|
|
202
|
+
const env = { ts, imports: importsOf(ts, sourceFile, { moduleKey: 'source', stringLiteralsOnly: true }), mappers: new Set(), topLevelVariables: new Set(), topLevelClasses: new Set(sourceFile.statements.filter(ts.isClassDeclaration)) };
|
|
203
|
+
for (const statement of sourceFile.statements) {
|
|
204
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
205
|
+
for (const variable of statement.declarationList.declarations) {
|
|
206
|
+
env.topLevelVariables.add(variable);
|
|
207
|
+
const value = variable.initializer && unwrap(ts, variable.initializer);
|
|
208
|
+
if (ts.isIdentifier(variable.name) && value && ts.isArrowFunction(value) && ts.isObjectLiteralExpression(unwrap(ts, value.body)) && mapping(env, value.body)) env.mappers.add(variable.name.text);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return env;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function classScaffolding(env, node, excluded) {
|
|
215
|
+
const { ts } = env;
|
|
216
|
+
const constructor = node.members.find(ts.isConstructorDeclaration);
|
|
217
|
+
if (hasHeritage(env, node, 'ICQRSHandler', '@modules/platform/cqrs', ts.SyntaxKind.ExtendsKeyword)
|
|
218
|
+
&& ['CommandHandler', 'QueryHandler'].some(name => decoratorsHave(env, node, name, '@nestjs/cqrs'))) {
|
|
219
|
+
if (canonicalConstructor(env, constructor, true)) excluded.add(constructor);
|
|
220
|
+
const members = propertyParameters(ts, constructor);
|
|
221
|
+
for (const method of node.members) if (ts.isMethodDeclaration(method) && nameText(ts, method.name) === 'process'
|
|
222
|
+
&& method.body?.statements.length === 1 && delegation(env, method, method.body.statements[0], members)) excluded.add(method);
|
|
223
|
+
}
|
|
224
|
+
if (hasHeritage(env, node, 'JobStep', '@modules/platform/jobs', ts.SyntaxKind.ImplementsKeyword)) jobMethods(env, node, constructor, excluded);
|
|
225
|
+
queueMethods(env, node, constructor, excluded);
|
|
226
|
+
readMethods(env, node, constructor, excluded);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function canonCloneNodes(ts, sourceFile, profile) {
|
|
230
|
+
if (profile === 'fe') return canonJsxCloneNodes(ts, sourceFile);
|
|
231
|
+
const excluded = new Set();
|
|
232
|
+
const env = canonEnvironment(ts, sourceFile);
|
|
233
|
+
const visit = node => {
|
|
234
|
+
if (queueDefinition(env, node)) { excluded.add(node); return; }
|
|
235
|
+
if (env.topLevelClasses.has(node)) classScaffolding(env, node, excluded);
|
|
236
|
+
ts.forEachChild(node, visit);
|
|
237
|
+
};
|
|
238
|
+
visit(sourceFile);
|
|
239
|
+
return excluded;
|
|
240
|
+
}
|