@starci/eslint-canon-fe 8.2.17 → 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 +13 -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,104 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { normalizedSymbolValue } from './ast-walks.mjs';
|
|
3
|
+
import { unwrapExpression } from './typescript.mjs';
|
|
4
|
+
|
|
5
|
+
/** Feature composition may inject block renders into a pure view without making the view a world owner. */
|
|
6
|
+
export class BlockRenderAnalysis {
|
|
7
|
+
constructor(renderer) {
|
|
8
|
+
this.renderer = renderer;
|
|
9
|
+
this.world = renderer.world;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
featureComposition(expression, expectedFile) {
|
|
13
|
+
const file = expression?.getSourceFile().fileName;
|
|
14
|
+
return Boolean(file && !expectedFile && this.world.insideAny(this.world.roots.features, file)
|
|
15
|
+
&& path.basename(file) === 'index.tsx');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
renderProp(expression, checker, expectedFile) {
|
|
19
|
+
return this.featureComposition(expression, expectedFile) && this.renderValue(expression, checker);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
blockTarget(expression, checker) {
|
|
23
|
+
const functions = this.world.expressionFunctions(expression, checker);
|
|
24
|
+
return functions.length > 0 && functions.every(fn => {
|
|
25
|
+
const file = fn.getSourceFile().fileName;
|
|
26
|
+
return this.world.sourceSet.has(path.resolve(file)) && path.basename(file) === 'index.tsx'
|
|
27
|
+
&& this.world.tierOf(this.world.roots.components, file)?.tier === 'blocks'
|
|
28
|
+
&& this.renderer.functionHasRender(fn, checker);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
callbacksRender(functions, checker, seen) {
|
|
33
|
+
return functions.length > 0 && functions.every(fn => {
|
|
34
|
+
const values = this.renderer.helpers.returnedExpressions(this.world.ts, fn);
|
|
35
|
+
return !this.world.functionUsesWorld(fn, checker) && values.length > 0
|
|
36
|
+
&& values.every(value => this.renderValue(value, checker, seen));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
renderValue(input, checker, seen = new Set()) {
|
|
41
|
+
const ts = this.world.ts;
|
|
42
|
+
const value = unwrapExpression(ts, input);
|
|
43
|
+
if (!value) return false;
|
|
44
|
+
if (value.kind === ts.SyntaxKind.NullKeyword || value.kind === ts.SyntaxKind.FalseKeyword) return true;
|
|
45
|
+
if (ts.isConditionalExpression(value)) return this.renderValue(value.whenTrue, checker, seen)
|
|
46
|
+
&& this.renderValue(value.whenFalse, checker, seen);
|
|
47
|
+
if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
|
|
48
|
+
return this.renderValue(value.right, checker, seen);
|
|
49
|
+
if (ts.isArrayLiteralExpression(value)) return value.elements.every(item => this.renderValue(item, checker, seen));
|
|
50
|
+
if (ts.isJsxFragment(value)) return this.fragmentBlocks(value, checker, seen);
|
|
51
|
+
if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value)) return this.jsxBlock(value, checker, seen);
|
|
52
|
+
if (ts.isCallExpression(value)) return this.createdBlock(value, checker, seen) || this.mappedBlocks(value, checker, seen);
|
|
53
|
+
if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) return this.callbacksRender([value], checker, seen);
|
|
54
|
+
return this.referencedBlocks(value, checker, seen);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fragmentBlocks(value, checker, seen) {
|
|
58
|
+
const ts = this.world.ts;
|
|
59
|
+
return value.children.every(child => {
|
|
60
|
+
if (ts.isJsxText(child)) return !child.text.trim();
|
|
61
|
+
return this.renderValue(ts.isJsxExpression(child) ? child.expression : child, checker, seen);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
jsxBlock(value, checker, seen) {
|
|
66
|
+
const ts = this.world.ts;
|
|
67
|
+
const opening = ts.isJsxElement(value) ? value.openingElement : value;
|
|
68
|
+
if (!this.blockTarget(opening.tagName, checker)) return false;
|
|
69
|
+
const boundary = { hasBoundary: true };
|
|
70
|
+
return this.renderer.jsxChildrenBoundary(value, opening, checker, seen, null, false, boundary)
|
|
71
|
+
&& this.renderer.jsxAttributesBoundary(opening, checker, seen, null, boundary);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
mappedBlocks(value, checker, seen) {
|
|
75
|
+
const ts = this.world.ts;
|
|
76
|
+
const callee = unwrapExpression(ts, value.expression);
|
|
77
|
+
if (!ts.isPropertyAccessExpression(callee) && !ts.isElementAccessExpression(callee)) return false;
|
|
78
|
+
let name = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
79
|
+
if (name === null && ts.isStringLiteralLike(callee.argumentExpression)) name = callee.argumentExpression.text;
|
|
80
|
+
return name === 'map' && value.arguments.length > 0
|
|
81
|
+
&& this.callbacksRender(this.world.expressionFunctions(value.arguments[0], checker), checker, seen);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
createdBlock(value, checker, seen) {
|
|
85
|
+
const ts = this.world.ts;
|
|
86
|
+
if (!this.renderer.isCreateElement(value, checker) || !value.arguments[0]
|
|
87
|
+
|| !this.blockTarget(value.arguments[0], checker)) return false;
|
|
88
|
+
const boundary = { hasBoundary: true };
|
|
89
|
+
const props = value.arguments[1] ? unwrapExpression(ts, value.arguments[1]) : null;
|
|
90
|
+
return this.renderer.createElementPropsBoundary(props, checker, seen, null, boundary, false)
|
|
91
|
+
&& value.arguments.slice(2).every(child => this.renderer.renderBoundary(child, checker, seen));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
referencedBlocks(value, checker, seen) {
|
|
95
|
+
const ts = this.world.ts;
|
|
96
|
+
const symbol = normalizedSymbolValue(ts, checker, this.renderer.helpers.selectedSymbol(ts, checker, value));
|
|
97
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
98
|
+
const nextSeen = new Set(seen).add(symbol);
|
|
99
|
+
const values = (symbol.getDeclarations?.() ?? []).flatMap(declaration => ts.isVariableDeclaration(declaration)
|
|
100
|
+
&& declaration.initializer ? [declaration.initializer] : []);
|
|
101
|
+
if (values.length) return values.every(item => this.renderValue(item, checker, nextSeen));
|
|
102
|
+
return this.callbacksRender(this.world.expressionFunctions(value, checker), checker, nextSeen);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { normalizedSymbolValue } from './ast-walks.mjs';
|
|
3
3
|
import { relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
+
import { BlockRenderAnalysis } from './frontend-block-render.mjs';
|
|
4
5
|
function importDeclarationFor(ts, node) {
|
|
5
6
|
for (let current = node; current; current = current.parent) if (ts.isImportDeclaration(current)) return current;
|
|
6
7
|
return null;
|
|
@@ -41,12 +42,14 @@ export class WorldRenderAnalysis {
|
|
|
41
42
|
constructor(world, helpers) {
|
|
42
43
|
this.world = world;
|
|
43
44
|
this.helpers = helpers;
|
|
45
|
+
this.blocks = new BlockRenderAnalysis(this);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
expressionHasRender(expression, checker, seen = new Set()) {
|
|
47
49
|
expression = unwrapExpression(this.world.ts, expression);
|
|
48
50
|
if (!expression) return false;
|
|
49
51
|
if (this.world.ts.isJsxElement(expression) || this.world.ts.isJsxSelfClosingElement(expression) || this.world.ts.isJsxFragment(expression)) return true;
|
|
52
|
+
if (this.world.ts.isArrayLiteralExpression(expression)) return expression.elements.some(item => this.expressionHasRender(item, checker, seen));
|
|
50
53
|
if (this.world.ts.isCallExpression(expression) && isReactCreateElement(this.world.ts, checker, expression)) return true;
|
|
51
54
|
if (this.world.ts.isConditionalExpression(expression)) return this.conditionalExpressionHasRender(expression, checker, seen);
|
|
52
55
|
const logicalRender = this.logicalExpressionHasRender(expression, checker, seen);
|
|
@@ -148,6 +151,22 @@ export class WorldRenderAnalysis {
|
|
|
148
151
|
|| this.world.expressionFunctions(expression, checker).some(fn => this.functionHasRender(fn, checker));
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
isCreateElement(expression, checker) {
|
|
155
|
+
return isReactCreateElement(this.world.ts, checker, expression);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
propValueBoundary(input, checker, seen, expectedFile, pureTarget) {
|
|
159
|
+
const ts = this.world.ts;
|
|
160
|
+
const value = unwrapExpression(ts, input);
|
|
161
|
+
if (pureTarget && this.blocks.renderProp(value, checker, expectedFile)) return true;
|
|
162
|
+
if (pureTarget && this.blocks.featureComposition(value, expectedFile) && ts.isObjectLiteralExpression(value)) {
|
|
163
|
+
return value.properties.every(property => !ts.isPropertyAssignment(property)
|
|
164
|
+
|| this.propValueBoundary(property.initializer, checker, seen, expectedFile, pureTarget));
|
|
165
|
+
}
|
|
166
|
+
return !this.expressionSuppliesRender(value, checker) || this.pureRenderTarget(value, checker, expectedFile)
|
|
167
|
+
|| this.renderBoundary(value, checker, seen, true, expectedFile);
|
|
168
|
+
}
|
|
169
|
+
|
|
151
170
|
routedLayoutChild(opening, child, checker) {
|
|
152
171
|
const ts = this.world.ts;
|
|
153
172
|
if (!ts.isJsxExpression(child) || !child.expression) return false;
|
|
@@ -216,7 +235,7 @@ export class WorldRenderAnalysis {
|
|
|
216
235
|
if (!pureTarget && !wrapper) return false;
|
|
217
236
|
const boundary = { hasBoundary: pureTarget };
|
|
218
237
|
const props = expression.arguments[1] ? unwrapExpression(ts, expression.arguments[1]) : null;
|
|
219
|
-
if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary)) return false;
|
|
238
|
+
if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary, pureTarget)) return false;
|
|
220
239
|
const children = expression.arguments.slice(2);
|
|
221
240
|
if (children.length) {
|
|
222
241
|
if (!children.every(child => this.renderBoundary(child, checker, seen, false, expectedFile))) return false;
|
|
@@ -225,19 +244,19 @@ export class WorldRenderAnalysis {
|
|
|
225
244
|
return boundary.hasBoundary;
|
|
226
245
|
}
|
|
227
246
|
|
|
228
|
-
createElementPropsBoundary(props, checker, seen, expectedFile, boundary) {
|
|
247
|
+
createElementPropsBoundary(props, checker, seen, expectedFile, boundary, pureTarget) {
|
|
229
248
|
const ts = this.world.ts;
|
|
230
249
|
if (!props || props.kind === ts.SyntaxKind.NullKeyword) return true;
|
|
231
250
|
if (!ts.isObjectLiteralExpression(props) || props.properties.some(property => ts.isSpreadAssignment(property))) return false;
|
|
232
|
-
return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary));
|
|
251
|
+
return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary, pureTarget));
|
|
233
252
|
}
|
|
234
253
|
|
|
235
|
-
propsPropertyBoundary(property, checker, seen, expectedFile, boundary) {
|
|
254
|
+
propsPropertyBoundary(property, checker, seen, expectedFile, boundary, pureTarget) {
|
|
236
255
|
const ts = this.world.ts;
|
|
237
256
|
if (!ts.isPropertyAssignment(property)) return true;
|
|
238
257
|
const value = unwrapExpression(ts, property.initializer);
|
|
258
|
+
if (!this.propValueBoundary(value, checker, seen, expectedFile, pureTarget)) return false;
|
|
239
259
|
if (!this.expressionSuppliesRender(value, checker)) return true;
|
|
240
|
-
if (!this.pureRenderTarget(value, checker, expectedFile) && !this.renderBoundary(value, checker, seen, true, expectedFile)) return false;
|
|
241
260
|
const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
|
|
242
261
|
if (['children', 'component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
|
|
243
262
|
return true;
|
|
@@ -270,7 +289,7 @@ export class WorldRenderAnalysis {
|
|
|
270
289
|
if (!pureTarget && !wrapper) return false;
|
|
271
290
|
const boundary = { hasBoundary: pureTarget };
|
|
272
291
|
if (!this.jsxChildrenBoundary(expression, opening, checker, seen, expectedFile, pureTarget, boundary)) return false;
|
|
273
|
-
if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary)) return false;
|
|
292
|
+
if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary, pureTarget)) return false;
|
|
274
293
|
return boundary.hasBoundary;
|
|
275
294
|
}
|
|
276
295
|
|
|
@@ -286,13 +305,13 @@ export class WorldRenderAnalysis {
|
|
|
286
305
|
return true;
|
|
287
306
|
}
|
|
288
307
|
|
|
289
|
-
jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary) {
|
|
308
|
+
jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary, pureTarget = false) {
|
|
290
309
|
const ts = this.world.ts;
|
|
291
310
|
return opening.attributes.properties.every(attribute => !ts.isJsxAttribute(attribute) || !attribute.initializer
|
|
292
|
-
|| this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary));
|
|
311
|
+
|| this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary, pureTarget));
|
|
293
312
|
}
|
|
294
313
|
|
|
295
|
-
jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary) {
|
|
314
|
+
jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary, pureTarget) {
|
|
296
315
|
const ts = this.world.ts;
|
|
297
316
|
const name = attribute.name.text;
|
|
298
317
|
if (['fallback', 'errorElement'].includes(name)) {
|
|
@@ -300,10 +319,10 @@ export class WorldRenderAnalysis {
|
|
|
300
319
|
if (ts.isJsxExpression(attribute.initializer)
|
|
301
320
|
&& !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
|
|
302
321
|
}
|
|
322
|
+
if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
|
|
323
|
+
&& !this.propValueBoundary(attribute.initializer.expression, checker, seen, expectedFile, pureTarget)) return false;
|
|
303
324
|
if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
|
|
304
325
|
&& this.expressionSuppliesRender(attribute.initializer.expression, checker)) {
|
|
305
|
-
if (!this.pureRenderTarget(attribute.initializer.expression, checker, expectedFile)
|
|
306
|
-
&& !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
|
|
307
326
|
if (['component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
|
|
308
327
|
}
|
|
309
328
|
return true;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
4
|
+
import { escapeRegExp } from '../../lib/regex.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* R106 `i18n-keys` (FE_I18N_KEYS). The catalogs of an app (`apps/<app>/src/modules/i18n/messages/<locale>.json`, slot fe.modules.i18n) and
|
|
@@ -12,7 +13,7 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
12
13
|
* own source (both branches of a conditional key are read). A key no locale's catalog holds is FE_I18N_KEYS, once per call. A computed
|
|
13
14
|
* key (a template with an interpolation, a variable) is not judged here; it counts as a read.
|
|
14
15
|
* - a key nobody reads is dead: a leaf of the app's catalogs that no string in the app's or the shared packages' source can be reading.
|
|
15
|
-
* A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard
|
|
16
|
+
* A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard within its dotted segment) equals a
|
|
16
17
|
* tail of its dotted path (`"title"`, `"card.title"`, `"orders.card.title"` all read `orders.card.title`); the test is deliberately loose
|
|
17
18
|
* so a key handed through props or a table of keys is never reported, and only a key that appears nowhere is.
|
|
18
19
|
*
|
|
@@ -39,7 +40,10 @@ const held = (leaves, full) => leaves.has(full) || [...leaves].some(leaf => leaf
|
|
|
39
40
|
|
|
40
41
|
// A pattern that is only wildcards (`${a}`, `${a}.${b}`) names nothing: it would read every key, so it reads none.
|
|
41
42
|
const tailMatches = (pattern, leafSegments) => pattern.some(segment => segment !== '*' && segment !== '') && pattern.length <= leafSegments.length
|
|
42
|
-
&& pattern.every((segment, index) =>
|
|
43
|
+
&& pattern.every((segment, index) => {
|
|
44
|
+
const leaf = leafSegments[leafSegments.length - pattern.length + index];
|
|
45
|
+
return segment instanceof RegExp ? segment.test(leaf) : segment === '*' || segment === leaf;
|
|
46
|
+
});
|
|
43
47
|
|
|
44
48
|
function catalogsPerApp(config, graph) {
|
|
45
49
|
const catalogs = new Map();
|
|
@@ -56,12 +60,21 @@ function catalogsPerApp(config, graph) {
|
|
|
56
60
|
return catalogs;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
|
|
64
|
+
function templatePatternOf(node) {
|
|
65
|
+
return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
|
|
66
|
+
if (!segment.includes('\u0000')) return segment;
|
|
67
|
+
const parts = segment.split('\u0000');
|
|
68
|
+
return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
function patternsOf(ts, files) {
|
|
60
73
|
const patterns = [];
|
|
61
74
|
for (const file of files) {
|
|
62
75
|
const visit = node => {
|
|
63
76
|
if (ts.isStringLiteralLike(node)) patterns.push(node.text.split('.'));
|
|
64
|
-
else if (ts.isTemplateExpression(node)) patterns.push(
|
|
77
|
+
else if (ts.isTemplateExpression(node)) patterns.push(templatePatternOf(node));
|
|
65
78
|
ts.forEachChild(node, visit);
|
|
66
79
|
};
|
|
67
80
|
visit(file.sourceFile);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { canonical } from './config.mjs';
|
|
3
3
|
import { hasAnyFlag, sourceLocation } from '../../lib/ts-ast.mjs';
|
|
4
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
4
5
|
|
|
5
6
|
const HELPER_DEPTH = 4;
|
|
6
7
|
|
|
@@ -110,6 +111,21 @@ const at = (env, rel, sourceFile, node, extra = {}) => ({ path: rel, ...sourceLo
|
|
|
110
111
|
/** The app root file of an app (apps/<name>/src/app.module.ts), or null when the program does not hold it. */
|
|
111
112
|
const appRoot = (env, name) => graphFile(env, `apps/${name}/src/app.module.ts`);
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Whether a repository-relative file is a composition group of an app: `apps/<app>/src/compose/<area>.ts`, the module list of
|
|
116
|
+
* one area that the app root spreads into its imports (slot allows `compose/<area>.ts` of every be.app.* slot).
|
|
117
|
+
*/
|
|
118
|
+
export const isComposeGroup = rel => /^apps\/[^/]+\/src\/compose\/[^/]+\.ts$/.test(rel) && !/\.(?:spec|d)\.ts$/.test(rel);
|
|
119
|
+
|
|
120
|
+
/** The app root and the composition groups of an app, in path order (the root first); [] when the program holds no root. */
|
|
121
|
+
const appCompositionFiles = (env, name) => {
|
|
122
|
+
const root = appRoot(env, name);
|
|
123
|
+
if (!root) return [];
|
|
124
|
+
const prefix = `apps/${name}/src/compose/`;
|
|
125
|
+
const groups = [...env.graph.files.values()].filter(file => file.rel.startsWith(prefix) && isComposeGroup(file.rel));
|
|
126
|
+
return [root, ...groups.sort((a, b) => byCodeUnit(a.rel, b.rel))];
|
|
127
|
+
};
|
|
128
|
+
|
|
113
129
|
/** The body a call or a spread identifier resolves to when it is a function or a const declared in a file of the program (not a package), else null. */
|
|
114
130
|
function helperBodyOf(env, checker, node) {
|
|
115
131
|
const { ts } = env;
|
|
@@ -200,6 +216,7 @@ export function machineKit({ context, graph }) {
|
|
|
200
216
|
valueOfProperty: bound(valueOfProperty),
|
|
201
217
|
at: bound(at),
|
|
202
218
|
appRoot: bound(appRoot),
|
|
219
|
+
appCompositionFiles: bound(appCompositionFiles),
|
|
203
220
|
};
|
|
204
221
|
}
|
|
205
222
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { machineKit } from './machine-ast.mjs';
|
|
2
|
+
import { isComposeGroup, machineKit } from './machine-ast.mjs';
|
|
3
3
|
import { treeOf } from './required-files.mjs';
|
|
4
4
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
5
5
|
import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
@@ -13,8 +13,9 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
|
13
13
|
* anywhere in a feature, and an `@Module` class (or a `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a
|
|
14
14
|
* feature file that is neither the application module nor the transport module of its own folder are refused;
|
|
15
15
|
* - apps import only transport modules, of the kinds their app kind allows: the `composedBy` list of each transport slot
|
|
16
|
-
* in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root
|
|
17
|
-
* lists a feature's application module, or the transport module
|
|
16
|
+
* in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root (app.module.ts
|
|
17
|
+
* or a composition group of its compose/ folder) that lists a feature's application module, or the transport module
|
|
18
|
+
* of another kind, is refused;
|
|
18
19
|
* - a composed root (an owner slot with `composedBy`, be.cli) holds one static module per folder, named after the folder
|
|
19
20
|
* (cli.module.ts, <group>/<group>.module.ts); an app of a composing kind lists the root module only, and an app of any
|
|
20
21
|
* other kind lists none of its modules.
|
|
@@ -22,7 +23,6 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
|
22
23
|
export const MODULE_PER_TRANSPORT_RULE_IDS = ['BE_MODULE_SHAPE'];
|
|
23
24
|
|
|
24
25
|
const RULE = 'BE_MODULE_SHAPE';
|
|
25
|
-
const APP_ROOT_FILE = 'app.module.ts';
|
|
26
26
|
|
|
27
27
|
function featureOwnerOf(resolver, rel) {
|
|
28
28
|
const owner = resolver.ownerOf(rel);
|
|
@@ -152,28 +152,37 @@ function inspectModuleDeclarations(checker, target, node, root, app, scan, seen)
|
|
|
152
152
|
return references;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const seen = new Set();
|
|
155
|
+
/** The module references of one composition file: the root's `imports` lists, or every array of a composition group (its module list). */
|
|
156
|
+
function inspectCompositionFile(file, app, scan, seen) {
|
|
157
|
+
const { kit, ts } = scan;
|
|
158
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
159
|
+
const group = isComposeGroup(file.rel);
|
|
161
160
|
let references = 0;
|
|
162
161
|
const judge = node => {
|
|
163
162
|
const call = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register';
|
|
164
163
|
if (ts.isCallExpression(node) && !call) return;
|
|
165
164
|
const target = call ? node.expression.expression : node;
|
|
166
|
-
references += inspectModuleDeclarations(checker, target, node,
|
|
165
|
+
references += inspectModuleDeclarations(checker, target, node, file, app, scan, seen);
|
|
167
166
|
};
|
|
168
|
-
kit.walk(
|
|
167
|
+
kit.walk(file.sourceFile, node => {
|
|
169
168
|
if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
|
|
170
169
|
for (const element of node.initializer.elements) judge(element);
|
|
170
|
+
} else if (group && ts.isArrayLiteralExpression(node)) {
|
|
171
|
+
for (const element of node.elements) judge(element);
|
|
171
172
|
} else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') judge(node);
|
|
172
173
|
return true;
|
|
173
174
|
});
|
|
174
175
|
return references;
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/** The module references of an app: its root `apps/<app>/src/app.module.ts` and the composition groups of `compose/` it spreads. */
|
|
179
|
+
function inspectAppRoot(app, scan) {
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
let references = 0;
|
|
182
|
+
for (const file of scan.kit.appCompositionFiles(app.name)) references += inspectCompositionFile(file, app, scan, seen);
|
|
183
|
+
return references;
|
|
184
|
+
}
|
|
185
|
+
|
|
177
186
|
function appModuleReferences(apps, scan) {
|
|
178
187
|
let references = 0;
|
|
179
188
|
for (const app of apps) references += inspectAppRoot(app, scan);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { machineKit } from './machine-ast.mjs';
|
|
2
|
+
import { isComposeGroup, machineKit } from './machine-ast.mjs';
|
|
3
3
|
import { callName } from './supabase-ast.mjs';
|
|
4
4
|
import { isTestWorldSlot } from '../test-world-slot.mjs';
|
|
5
5
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
@@ -7,7 +7,8 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
7
7
|
/**
|
|
8
8
|
* R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
|
|
9
9
|
* reference's `CodingModule imports DeviceModule` shape). Each capability has one representative module: the only module
|
|
10
|
-
* of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })
|
|
10
|
+
* of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`, or in a
|
|
11
|
+
* composition group `apps/<app>/src/compose/<area>.ts` the root spreads (an app root here means the root and its groups).
|
|
11
12
|
* It may split itself into sub-modules imported as plain `imports: [Sub]`; a sub-module is never in an app and has one
|
|
12
13
|
* importer, its parent. Over every `@Module` class of the program:
|
|
13
14
|
*
|
|
@@ -57,6 +58,7 @@ function enclosingModule(ts, modules, node) {
|
|
|
57
58
|
/** Records the reference an element of an imports list, or a register call, makes to a module. */
|
|
58
59
|
function noteReference(scan, element, viaImports) {
|
|
59
60
|
const { ts, kit, modules, checker, file, seen, references, isAppRoot } = scan;
|
|
61
|
+
if (seen.has(element)) return;
|
|
60
62
|
const call = callName(ts, element) === 'register';
|
|
61
63
|
if (ts.isCallExpression(element) && !call) return;
|
|
62
64
|
const target = targetOf(kit, modules, checker, call ? element.expression.expression : element);
|
|
@@ -72,14 +74,19 @@ const isImportsList = (scan, node) => scan.ts.isPropertyAssignment(node) && scan
|
|
|
72
74
|
const isStrayGlobal = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'isGlobal'
|
|
73
75
|
&& node.initializer.kind === scan.ts.SyntaxKind.TrueKeyword && !scan.isAppRoot(scan.file) && !scan.isWorld(scan.file);
|
|
74
76
|
|
|
77
|
+
// A composition group's module list is any array of the group: the root spreads it into its imports.
|
|
78
|
+
const isGroupList = (scan, node) => scan.ts.isArrayLiteralExpression(node) && isComposeGroup(scan.file.rel);
|
|
79
|
+
|
|
75
80
|
function visitReferenceNode(scan, node) {
|
|
76
81
|
if (isImportsList(scan, node)) {
|
|
77
82
|
for (const element of node.initializer.elements) noteReference(scan, element, true);
|
|
83
|
+
} else if (isGroupList(scan, node)) {
|
|
84
|
+
for (const element of node.elements) noteReference(scan, element, true);
|
|
78
85
|
} else if (callName(scan.ts, node) === 'register' && !scan.seen.has(node)) {
|
|
79
86
|
noteReference(scan, node, false);
|
|
80
87
|
}
|
|
81
88
|
if (isStrayGlobal(scan, node)) {
|
|
82
|
-
scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
|
|
89
|
+
scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts or a group of apps/<app>/src/compose/), where the representative module of a capability is registered; a module never decides its own globality.');
|
|
83
90
|
}
|
|
84
91
|
return true;
|
|
85
92
|
}
|
|
@@ -170,11 +177,12 @@ export function checkRegisterOnce(input) {
|
|
|
170
177
|
const { ts } = kit;
|
|
171
178
|
const violations = [];
|
|
172
179
|
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
173
|
-
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
|
|
180
|
+
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && (path.posix.basename(file.rel) === 'app.module.ts' || isComposeGroup(file.rel));
|
|
174
181
|
// The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
|
|
175
182
|
// `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
|
|
176
183
|
const isWorld = file => isTestWorldSlot(file.slot);
|
|
177
|
-
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`
|
|
184
|
+
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`
|
|
185
|
+
|| (isComposeGroup(file.rel) && file.rel.startsWith(`apps/${app.name}/src/compose/`)))?.name ?? null;
|
|
178
186
|
const modules = moduleDeclarations(graph, kit, ts);
|
|
179
187
|
const references = moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report);
|
|
180
188
|
const rootReferences = references.filter(item => item.root);
|
|
@@ -4,6 +4,7 @@ import { lsFiles } from '../../api/git/ls-files.mjs';
|
|
|
4
4
|
import { revParseQuery } from '../../api/git/rev-parse-query.mjs';
|
|
5
5
|
import { gitOutputOf } from '../../lib/git.mjs';
|
|
6
6
|
import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
7
|
+
import { requiredFileApplies } from '../slot-required.mjs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* HFS check 5: the files and directories the slot manifest requires (knowledge/hfs/slots.yaml `requires`,
|
|
@@ -173,7 +174,7 @@ function requirementExists(tree, target) {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
function reportMissingRequirement(requirement, tree, report) {
|
|
176
|
-
if (requirementExists(tree, requirement.target)) return;
|
|
177
|
+
if (requirementExists(tree, requirement.target) || !requiredFileApplies(requirement.slot, requirement.target, tree.files)) return;
|
|
177
178
|
const rootExists = !requirement.root || tree.directories.has(requirement.root);
|
|
178
179
|
if (!rootExists) {
|
|
179
180
|
report(ruleFor(requirement.slot, requirement.target, false), requirement.root, requirement.slot, requirement.root, { kind: 'directory' },
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { catFile } from '../../api/git/cat-file.mjs';
|
|
2
|
+
import { diff } from '../../api/git/diff.mjs';
|
|
3
|
+
import { gitOutputOf } from '../../lib/git.mjs';
|
|
4
|
+
|
|
5
|
+
/** Eligible additions, copies, edits and rename destinations in the index, read in one blob batch. */
|
|
6
|
+
export function stagedConsumerSources(root, accept) {
|
|
7
|
+
try {
|
|
8
|
+
const options = { cwd: root, maxBuffer: 256 * 1024 * 1024 };
|
|
9
|
+
const files = gitOutputOf(diff(['--cached', '--relative', '--name-only', '--diff-filter=ACMR', '-z', '--', '.'], options))
|
|
10
|
+
.split('\0').filter(file => file && accept(file));
|
|
11
|
+
if (!files.length) return [];
|
|
12
|
+
// :./ keeps paths relative to this side's root even when git's top level is the parent app folder.
|
|
13
|
+
// -z terminates requests with NUL; output headers and blob trailers still use LF.
|
|
14
|
+
const bytes = gitOutputOf(catFile(['--batch', '-z'], {
|
|
15
|
+
...options, encoding: null, input: files.map(file => `:./${file}\0`).join(''),
|
|
16
|
+
}));
|
|
17
|
+
if (!Buffer.isBuffer(bytes)) return [];
|
|
18
|
+
const sources = [];
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const end = bytes.indexOf(10, cursor);
|
|
22
|
+
if (end < 0) return [];
|
|
23
|
+
const header = /^[0-9a-f]+ blob (\d+)$/u.exec(bytes.subarray(cursor, end).toString('utf8'));
|
|
24
|
+
if (!header) return []; // An index changed during the read: discard this supplement, keep disk consumers.
|
|
25
|
+
const size = Number(header[1]);
|
|
26
|
+
const start = end + 1;
|
|
27
|
+
const next = start + size;
|
|
28
|
+
if (!Number.isSafeInteger(size) || next >= bytes.length || bytes[next] !== 10) return [];
|
|
29
|
+
sources.push({ file, text: bytes.subarray(start, next).toString('utf8') });
|
|
30
|
+
cursor = next + 1;
|
|
31
|
+
}
|
|
32
|
+
return sources;
|
|
33
|
+
} catch {
|
|
34
|
+
// A non-git fixture (or an unavailable index) still uses the existing filesystem consumers.
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// supabase-ast.mjs - shared TypeScript-symbol and expression helpers for the front-end and back-end Supabase
|
|
2
2
|
// architecture checks. Recognition follows imported declarations and inferred types, never local spelling alone.
|
|
3
|
+
import { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
4
|
+
|
|
3
5
|
export const SUPABASE_MODULES = new Set(['@supabase/ssr', '@supabase/supabase-js']);
|
|
4
6
|
|
|
5
7
|
export const moduleNameOf = (ts, node) => {
|
|
@@ -38,12 +40,7 @@ const signatureFromSupabase = (checker, call) => {
|
|
|
38
40
|
try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
export const unwrap = (ts, node) => {
|
|
42
|
-
let current = node;
|
|
43
|
-
while (current && (ts.isParenthesizedExpression(current) || ts.isAwaitExpression(current) || ts.isAsExpression(current)
|
|
44
|
-
|| ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current))) current = current.expression;
|
|
45
|
-
return current;
|
|
46
|
-
};
|
|
43
|
+
export const unwrap = (ts, node) => unwrapExpression(ts, node, { awaitExpressions: true, satisfiesExpressions: false });
|
|
47
44
|
|
|
48
45
|
const isSupabaseValue = (kit, checker, node) => {
|
|
49
46
|
const current = unwrap(kit.ts, node);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { machineKit } from './machine-ast.mjs';
|
|
5
5
|
import { importedFrom, reportAt, unwrap } from './supabase-ast.mjs';
|
|
6
|
+
import { accessName } from './backend-app-files.mjs';
|
|
6
7
|
|
|
7
8
|
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
8
9
|
const BE_JWT_VERIFIED = 'BE_SUPABASE_JWT_VERIFIED';
|
|
@@ -47,12 +48,6 @@ const importedCall = (kit, checker, call) => {
|
|
|
47
48
|
return owner?.name === 'default' ? { module: owner.module, name: call.expression.name.text } : null;
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
const accessName = (ts, node) => {
|
|
51
|
-
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
52
|
-
if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
|
|
53
|
-
return null;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
51
|
const requestLike = (ts, node) => {
|
|
57
52
|
const current = unwrap(ts, node);
|
|
58
53
|
if (ts.isIdentifier(current)) return /^(?:req|request)$/iu.test(current.text);
|
|
@@ -14,8 +14,15 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
14
14
|
*/
|
|
15
15
|
export const TIER_RULE_IDS = ['BE_TIER_DIRECTION', 'BE_FEATURE_IMPORTS_FEATURE', 'BE_KIND_ISOLATION', 'FE_TIER_DIRECTION', 'FE_APP_ISOLATION', 'ARCH_OWNER_CYCLE'];
|
|
16
16
|
|
|
17
|
+
function tierDirectionText({ fromTier, toTier, mayImport, kindRestriction, fromKind, toKind }) {
|
|
18
|
+
if (!kindRestriction) return `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`;
|
|
19
|
+
const source = fromKind ?? 'no folder kind';
|
|
20
|
+
const target = toKind ?? 'no folder kind';
|
|
21
|
+
return `a ${fromTier} may import ${toTier} owners only from kinds ${kindRestriction.from.join(', ')} to kinds ${kindRestriction.to.join(', ')}; it imports ${source} -> ${target}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
const REASON_TEXT = {
|
|
18
|
-
tierDirection:
|
|
25
|
+
tierDirection: tierDirectionText,
|
|
19
26
|
layerOrder: ({ fromLayer, toLayer }) => `a ${fromLayer} component may import only the layers after it; it imports a ${toLayer}`,
|
|
20
27
|
crossApp: ({ from, to }) => `app ${from} imports app ${to}; apps never import each other, shared code is a packages/<pkg> slot`,
|
|
21
28
|
};
|
|
@@ -9,6 +9,7 @@ import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule
|
|
|
9
9
|
import { readJsonFile } from '../../lib/json.mjs';
|
|
10
10
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
11
11
|
import { locateDeclaration } from '../slots.mjs';
|
|
12
|
+
export { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
12
13
|
|
|
13
14
|
const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
14
15
|
const SCRIPT_TAIL = '[cm]?[jt]sx?';
|
|
@@ -399,14 +400,6 @@ export function reachableViolation(edges, firstEdge, forbidden, { follow = () =>
|
|
|
399
400
|
/** A framework identity the checkers cannot prove statically. */
|
|
400
401
|
export const UNPROVEN_FRAMEWORK = '(unproven framework identity)';
|
|
401
402
|
|
|
402
|
-
/** The expression under parentheses, type assertions, non-null and satisfies wrappers. */
|
|
403
|
-
export function unwrapExpression(ts, expression) {
|
|
404
|
-
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
405
|
-
|| ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression)
|
|
406
|
-
|| (ts.isSatisfiesExpression?.(expression) ?? false))) expression = expression.expression;
|
|
407
|
-
return expression;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
403
|
/* ---- the AST walks every architecture checker shares ---- */
|
|
411
404
|
|
|
412
405
|
/** The names of `expected` an import or re-export statement binds (all of them for a namespace or star form). */
|
|
@@ -4,6 +4,7 @@ import { machineKit } from './machine-ast.mjs';
|
|
|
4
4
|
import { constructorDependencies } from './constructor-deps.mjs';
|
|
5
5
|
import { treeOf } from './required-files.mjs';
|
|
6
6
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
7
|
+
import { importsOf } from '../../lib/ts-analysis.mjs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
|
|
@@ -25,17 +26,6 @@ export const UNIT_SPEC_PROVIDERS_RULE_IDS = ['BE_SPEC_QUALITY'];
|
|
|
25
26
|
const RULE = 'BE_SPEC_QUALITY';
|
|
26
27
|
const SPEC_ROOT = /^(?:src|apps)\//u;
|
|
27
28
|
|
|
28
|
-
/** local name -> {name, module} for every named import of a source file. */
|
|
29
|
-
function importsOf(ts, sourceFile) {
|
|
30
|
-
const imports = new Map();
|
|
31
|
-
for (const statement of sourceFile.statements) {
|
|
32
|
-
const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : null;
|
|
33
|
-
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
34
|
-
for (const element of bindings.elements) imports.set(element.name.text, { name: (element.propertyName ?? element.name).text, module: statement.moduleSpecifier.text });
|
|
35
|
-
}
|
|
36
|
-
return imports;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
29
|
/** The `Test.createTestingModule(...)` calls of a spec. */
|
|
40
30
|
function testingModules(ts, sourceFile) {
|
|
41
31
|
const calls = [];
|