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