@starci/eslint-canon-fe 8.2.18 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +19 -1
  3. package/docs/size-and-state-budget.md +6 -6
  4. package/drawing.mjs +65 -28
  5. package/package.json +1 -1
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/slots.yaml +99 -43
  8. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  9. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  10. package/runtime/scripts/api/git/diff.mjs +5 -0
  11. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  12. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  13. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  14. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  15. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  16. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  17. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  18. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  19. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  20. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  21. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  22. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  23. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  24. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  25. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  26. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  27. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  29. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  30. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  31. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  32. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  33. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  34. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  35. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  36. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  37. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  38. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  39. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  40. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  41. package/size-and-state-budget.mjs +11 -8
@@ -0,0 +1,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: [], lineStarts: sourceFile.getLineStarts(), literalKinds: literalKindSet(SyntaxKind) };
84
+ const walk = { ts, sourceFile, kinds: [], lines: [], types: [], segments: [], segment: 0, excluded,
85
+ lineStarts: sourceFile.getLineStarts(), literalKinds: literalKindSet(SyntaxKind) };
79
86
  for (const statement of sourceFile.statements) {
80
87
  if (!isDroppedStatement(SyntaxKind, statement)) visitToken(walk, statement, false);
81
88
  }
82
- return { kinds: Int32Array.from(walk.kinds), lines: Int32Array.from(walk.lines), types: Uint8Array.from(walk.types) };
89
+ return { kinds: Int32Array.from(walk.kinds), lines: Int32Array.from(walk.lines), types: Uint8Array.from(walk.types), segments: Int32Array.from(walk.segments) };
83
90
  }
84
91
 
85
92
  /** The app a file belongs to, by the slot binding of the manifest; null for a file of no app. */
@@ -142,15 +149,15 @@ const addToBucket = (buckets, key, item) => {
142
149
 
143
150
  const addFileWindows = (buckets, file, fileIndex, N, T, hashing) => {
144
151
  const { pow1, pow2, B1, B2 } = hashing;
145
- const { kinds, lines } = file;
152
+ const { kinds, lines, segments } = file;
146
153
  const count = kinds.length;
147
154
  const { pre1, pre2 } = prefixHashes(kinds, B1, B2);
148
155
  let end = 0;
149
156
  for (let start = 0; start < count; start += 1) {
150
- if (start > 0 && lines[start] === lines[start - 1]) continue;
157
+ if (start > 0 && lines[start] === lines[start - 1] && segments[start] === segments[start - 1]) continue;
151
158
  const limit = lines[start] + N;
152
159
  if (end < start) end = start;
153
- while (end < count && lines[end] < limit) end += 1;
160
+ while (end < count && lines[end] < limit && segments[end] === segments[start]) end += 1;
154
161
  if (end === start || lines[end - 1] - lines[start] + 1 < N) continue;
155
162
  const length = end - start;
156
163
  if (length < T) continue;
@@ -224,7 +231,8 @@ const cloneBlocks = (entries, hits, N) => {
224
231
  group.sort((x, y) => x[0] - y[0]);
225
232
  let [start, end] = group[0];
226
233
  for (const [nextStart, nextEnd] of group.slice(1)) {
227
- if (nextStart <= end) { end = Math.max(end, nextEnd); continue; }
234
+ if (nextStart <= end && fileA.segments[nextStart] === fileA.segments[start]
235
+ && fileB.segments[nextStart + diag] === fileB.segments[start + diag]) { end = Math.max(end, nextEnd); continue; }
228
236
  appendCloneBlock(blocks, fileA, fileB, start, end, diag, N);
229
237
  start = nextStart; end = nextEnd;
230
238
  }
@@ -263,7 +271,8 @@ export function checkClones({ config, context, graph } = {}) {
263
271
  if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
264
272
  const unit = graph.unit(rel);
265
273
  if (!unit) continue;
266
- entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
274
+ const sourceFile = graph.files.get(rel).sourceFile;
275
+ entries.push({ rel, unit, ...tokenize(ts, sourceFile, { excluded: canonCloneNodes(ts, sourceFile, graph.profile) }) });
267
276
  }
268
277
  const buckets = cloneBuckets(entries, N, T);
269
278
  const blocks = cloneBlocks(entries, cloneHits(entries, buckets), N);
@@ -226,18 +226,20 @@ const passConnections = (argument, scope) => {
226
226
  const checkAppRegistrations = (config, kit, ts, byName, report) => {
227
227
  let registrations = 0;
228
228
  for (const app of config.apps) {
229
- const root = kit.appRoot(app.name);
230
- if (!root) continue;
231
- const checker = kit.checkerOf(root.sourceFile);
232
- const scope = { ts, kit, checker, byName, passed: new Map(), root, app, report };
233
- kit.walk(root.sourceFile, node => {
234
- const receiver = registerReceiver(ts, node);
235
- if (!receiver) return;
236
- const owner = kit.declarationsOf(checker, receiver).map(kit.ownerOfDeclaration).find(Boolean);
237
- if (owner?.tier !== 'platform' || owner.name !== 'database') return;
238
- registrations += 1;
239
- for (const argument of node.arguments) passConnections(argument, scope);
240
- });
229
+ // The root and its composition groups (compose/<area>.ts) are one composition: a connection is passed once across them.
230
+ const passed = new Map();
231
+ for (const root of kit.appCompositionFiles(app.name)) {
232
+ const checker = kit.checkerOf(root.sourceFile);
233
+ const scope = { ts, kit, checker, byName, passed, root, app, report };
234
+ kit.walk(root.sourceFile, node => {
235
+ const receiver = registerReceiver(ts, node);
236
+ if (!receiver) return;
237
+ const owner = kit.declarationsOf(checker, receiver).map(kit.ownerOfDeclaration).find(Boolean);
238
+ if (owner?.tier !== 'platform' || owner.name !== 'database') return;
239
+ registrations += 1;
240
+ for (const argument of node.arguments) passConnections(argument, scope);
241
+ });
242
+ }
241
243
  }
242
244
  return registrations;
243
245
  };
@@ -74,10 +74,16 @@ const nameFromIdentifier = (ctx, checker, node, depth) => {
74
74
  ? nameOfExpression(ctx, kit.checkerOf(declaration.getSourceFile()), declaration.initializer, depth + 1) : null;
75
75
  };
76
76
 
77
+ /** True when `declaration` is a property of the `<app>.env.ts` reader's own interface that sits beside the options file declaring `target`: the reader feeds the options value, so its property is the same slot. */
78
+ const feedsOptions = (declaration, target) => {
79
+ const home = declaration?.getSourceFile().fileName ?? '';
80
+ return home.endsWith('.env.ts') && path.posix.dirname(home) === path.posix.dirname(target.getSourceFile().fileName);
81
+ };
82
+
77
83
  const nameFromProperty = (ctx, checker, node, depth) => {
78
84
  const target = ctx.kit.declarationsOf(checker, node.name)[0];
79
85
  if (!target) return null;
80
- const names = new Set(assignmentsOf(ctx, node.name.text).filter(item => item.declaration === target)
86
+ const names = new Set(assignmentsOf(ctx, node.name.text).filter(item => item.declaration === target || feedsOptions(item.declaration, target))
81
87
  .map(item => nameOfExpression(ctx, item.checker, item.initializer, depth + 1)).filter(Boolean));
82
88
  return names.size === 1 ? [...names][0] : null;
83
89
  };
@@ -282,30 +288,35 @@ const composedConnection = (kit, declared, checker, inner) => {
282
288
  return value !== null && declared.has(value) ? value : null;
283
289
  };
284
290
 
285
- const collectComposedConnections = (kit, declared, checker, call, items) => {
291
+ const collectComposedConnections = (kit, declared, checker, call, file, items) => {
286
292
  for (const argument of call.arguments) {
287
293
  kit.walk(argument, inner => {
288
294
  const value = composedConnection(kit, declared, checker, inner);
289
295
  if (value === null) return true;
290
- items.push({ connection: value, node: inner });
296
+ items.push({ connection: value, node: inner, file });
291
297
  return false;
292
298
  });
293
299
  }
294
300
  };
295
301
 
296
- /** The declared connection names the composition root of `app` passes to the database module registration, as [{connection, node}] in source order. */
302
+ /**
303
+ * The declared connection names the composition of `app` (its root app.module.ts and the composition groups of compose/) passes to
304
+ * the database module registration, as [{connection, node, file}] in path then source order.
305
+ */
297
306
  export function connectionsComposedBy(kit, app) {
298
307
  const { ts } = kit;
299
- const root = kit.appRoot(app);
300
- if (!root) return { root: null, items: [] };
308
+ const files = kit.appCompositionFiles(app);
309
+ if (!files.length) return { root: null, items: [] };
301
310
  const declared = new Set(kit.resolver.repo.connections.map(connection => connection.name));
302
- const checker = kit.checkerOf(root.sourceFile);
303
311
  const items = [];
304
- kit.walk(root.sourceFile, node => {
305
- if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return;
306
- const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
307
- if (owner?.tier !== 'platform' || owner.name !== 'database') return;
308
- collectComposedConnections(kit, declared, checker, node, items);
309
- });
310
- return { root, items };
312
+ for (const file of files) {
313
+ const checker = kit.checkerOf(file.sourceFile);
314
+ kit.walk(file.sourceFile, node => {
315
+ if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return;
316
+ const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
317
+ if (owner?.tier !== 'platform' || owner.name !== 'database') return;
318
+ collectComposedConnections(kit, declared, checker, node, file, items);
319
+ });
320
+ }
321
+ return { root: files[0], items };
311
322
  }
@@ -23,8 +23,7 @@ const RUNNER_KINDS = new Set(['migrate', 'cli']);
23
23
  const compositionsOf = (kit, resolver, model, appOf) => {
24
24
  const composed = new Map(resolver.repo.apps.map(app => [app.name, { items: [], unresolved: false }]));
25
25
  for (const app of resolver.repo.apps) {
26
- const { root, items } = connectionsComposedBy(kit, app.name);
27
- for (const item of items) composed.get(app.name).items.push({ ...item, file: root });
26
+ for (const item of connectionsComposedBy(kit, app.name).items) composed.get(app.name).items.push(item);
28
27
  }
29
28
  for (const site of model.registrations) {
30
29
  const app = appOf(site.file.rel);
@@ -25,7 +25,7 @@
25
25
  * (ruleParams.be.logicRoles: `<name>.policy.spec.ts`, `<name>.client.spec.ts`, ...), a
26
26
  * unit spec of a webhook, gateway or subscription door (`.webhook.spec.ts`, `.gateway.spec.ts`, `.subscription.spec.ts`: a door has no service of its own), a
27
27
  * `*.builder.ts` under src/tests/fixtures/builders, and a file of the test world (slots be.tests.world and be.tests.world.kit, which
28
- * open and migrate the real databases of the e2e run) read from disk count as consumers, because a spec can only provide an
28
+ * open and migrate the real databases of the e2e run) read from disk or staged in the git index count as consumers, because a spec can only provide an
29
29
  * Inject*() token or a param type the entry exports, and the world wires the same capabilities the apps do. An integration
30
30
  * spec (slot be.tests.integration, `src/tests/integration/<capability>/*.integration-spec.ts`) counts as a consumer of exactly one
31
31
  * owner: the integration (slot be.integrations, `src/modules/integrations/<provider>/`) whose provider folder is its capability
@@ -38,6 +38,7 @@ import { canonical } from './config.mjs';
38
38
  import path from 'node:path';
39
39
  import { treeOf } from './required-files.mjs';
40
40
  import { globExpression } from '../../lib/glob.mjs';
41
+ import { stagedConsumerSources } from './staged-consumers.mjs';
41
42
 
42
43
  export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
43
44
 
@@ -241,10 +242,12 @@ function inIntegrationOf(graph, to, provider) {
241
242
  }
242
243
 
243
244
  /** The pseudo edges of one test consumer file: its import/export declarations that resolve to graph files it may reach. */
244
- function consumerFileEdges({ ts, options, graph, config }, rel, provider) {
245
+ function consumerFileEdges({ ts, options, graph, config }, rel, provider, indexedText) {
245
246
  const abs = path.join(config.root, ...rel.split('/'));
246
- let text;
247
- try { text = fs.readFileSync(abs, 'utf8'); } catch { return []; }
247
+ let text = indexedText;
248
+ if (text === undefined) {
249
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return []; }
250
+ }
248
251
  const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true);
249
252
  const edges = [];
250
253
  for (const statement of sourceFile.statements) {
@@ -257,17 +260,19 @@ function consumerFileEdges({ ts, options, graph, config }, rel, provider) {
257
260
  }
258
261
 
259
262
  /**
260
- * Pseudo edges (read from disk, outside the production program) to graph files: from the unit role specs, the fixture builders and
263
+ * Pseudo edges (read from disk and staged index sources, outside the production program) to graph files: from the unit role specs, the fixture builders and
261
264
  * the test world files to anything, and from an integration spec only to the integration of its own provider folder.
262
265
  */
263
266
  function testConsumerEdges({ context, graph, config }) {
264
267
  const resolution = { ts: context.ts, options: context.projects?.[0]?.options ?? {}, graph, config };
265
- const consumers = [...treeOf(config.root).files]
266
- .map(file => ({ file, any: testConsumer(graph, file) }))
267
- .map(({ file, any }) => ({ file, provider: any ? null : integrationSpecProvider(graph, file), any }))
268
+ // Live lint keeps tracked disk consumers; staged snapshots add the consumers present in the same commit.
269
+ const staged = stagedConsumerSources(config.root, file => testConsumer(graph, file) || integrationSpecProvider(graph, file) !== null);
270
+ const consumers = [...[...treeOf(config.root).files].map(file => ({ file })), ...staged]
271
+ .map(source => ({ ...source, any: testConsumer(graph, source.file) }))
272
+ .map(({ file, text, any }) => ({ file, text, provider: any ? null : integrationSpecProvider(graph, file), any }))
268
273
  .filter(({ any, provider }) => any || provider !== null)
269
274
  .sort((a, b) => a.file.localeCompare(b.file));
270
- return consumers.flatMap(({ file: rel, provider }) => consumerFileEdges(resolution, rel, provider));
275
+ return consumers.flatMap(({ file: rel, text, provider }) => consumerFileEdges(resolution, rel, provider, text));
271
276
  }
272
277
 
273
278
  const edgeIndex = edges => {