@starci/eslint-canon-fe 8.3.0 → 8.4.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 +6 -0
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/env-owner.mjs +120 -9
- package/function-budget.mjs +49 -0
- package/hooks-folder.mjs +149 -18
- package/icon.mjs +67 -0
- package/index.mjs +47 -0
- package/lib/own-hook-origin.mjs +53 -0
- package/lib/program-for.mjs +22 -0
- package/lib/pure-owner-helper.mjs +148 -0
- package/lib/redux-hook-origin.mjs +44 -0
- package/next-conventions.mjs +38 -35
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/served-locale.mjs +80 -9
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- package/transport.mjs +126 -59
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { isInside } from './config.mjs';
|
|
3
3
|
import { relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
-
import { commonJsRequireReasons, decoratorCallee, moduleExportsOf, nodeDecorators, normalizedSymbol, normalizedSymbolValue, programSourcesOf, returnedExpressions, selectedNode, valueSymbol, violation } from './ast-walks.mjs';
|
|
4
|
+
import { commonJsRequireReasons, constructedDecoratorKind, decoratorCallee, moduleExportsOf, mutableDecoratorKind, nodeDecorators, normalizedSymbol, normalizedSymbolValue, programSourcesOf, returnedExpressions, selectedNode, valueSymbol, violation } from './ast-walks.mjs';
|
|
5
5
|
import { createBackendSourceShapeChecker } from './source-names-shape.mjs';
|
|
6
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
6
7
|
|
|
7
8
|
const SOURCE_LAYOUT_RULE_ID = 'BE_FEATURE_LAYOUT_INVALID';
|
|
8
9
|
const SOURCE_NAME_RULE_ID = 'BE_SOURCE_FORM';
|
|
9
10
|
const FRAMEWORK_EXPORTS = new Map([
|
|
10
|
-
['@nestjs/graphql', new Set(['Args', 'ArgsType', 'InputType', 'Mutation', 'ObjectType', 'Query', 'registerEnumType'])],
|
|
11
|
+
['@nestjs/graphql', new Set(['Args', 'ArgsType', 'InputType', 'Mutation', 'ObjectType', 'Query', 'Subscription', 'registerEnumType'])],
|
|
11
12
|
['typeorm', new Set(['Entity', 'EntitySchema', 'MigrationInterface', 'ViewEntity'])],
|
|
12
13
|
]);
|
|
13
14
|
const APPLICATION_ROLES = new Set(['command', 'contracts', 'handler', 'query']);
|
|
@@ -237,6 +238,11 @@ function graphqlField({ config, context, sourceFile, node, decorator, kind, nami
|
|
|
237
238
|
}
|
|
238
239
|
const methodName = node.name && (context.ts.isIdentifier(node.name) || context.ts.isStringLiteralLike(node.name)) ? node.name.text : null;
|
|
239
240
|
const name = selected.status === 'literal' ? selected.value : methodName;
|
|
241
|
+
if (selected.status === 'literal') {
|
|
242
|
+
if (!methodName) namingReasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot prove the @${kind} method name`);
|
|
243
|
+
else if (selected.value !== methodName) violations.push(violation(config, sourceFile, selected.node, SOURCE_NAME_RULE_ID,
|
|
244
|
+
`GraphQL @${kind} field name ${selected.value} must equal its method name ${methodName}.`));
|
|
245
|
+
}
|
|
240
246
|
if (!name) namingReasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot prove the @${kind} field name`);
|
|
241
247
|
else if (!camelCase(name)) violations.push(violation(config, sourceFile, selected.node ?? node.name ?? node, SOURCE_NAME_RULE_ID,
|
|
242
248
|
`GraphQL field name ${name} must be camelCase.`));
|
|
@@ -316,7 +322,46 @@ const checkBackendSourceShapeImpl = createBackendSourceShapeChecker({
|
|
|
316
322
|
TRACE_NEW,
|
|
317
323
|
});
|
|
318
324
|
|
|
325
|
+
function inspectSubscriptionSource({ config, context, sourceFile, checker, framework, configuredRoot, namingReasons, violations }) {
|
|
326
|
+
const visit = node => {
|
|
327
|
+
for (const decorator of nodeDecorators(context.ts, node)) {
|
|
328
|
+
const kind = decoratorKind(context.ts, checker, decorator, framework.bySymbol);
|
|
329
|
+
if (kind === 'Subscription') graphqlField({ config, context, sourceFile, node, decorator, kind, namingReasons, violations });
|
|
330
|
+
else if (!kind && !configuredRoot) {
|
|
331
|
+
const dynamic = mutableDecoratorKind(context.ts, checker, decorator, framework.bySymbol)
|
|
332
|
+
?? constructedDecoratorKind(context.ts, checker, decorator, framework.bySymbol, TRACE_NEW);
|
|
333
|
+
if (['Subscription', 'multiple framework', 'unproven framework'].includes(dynamic)) {
|
|
334
|
+
namingReasons.push(`${relativePath(config.root, sourceFile.fileName)} uses a mutable or constructed ${dynamic} decorator identity`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
context.ts.forEachChild(node, visit);
|
|
339
|
+
};
|
|
340
|
+
visit(sourceFile);
|
|
341
|
+
}
|
|
342
|
+
|
|
319
343
|
export function checkBackendSourceShape(config, context) {
|
|
320
|
-
|
|
344
|
+
const result = checkBackendSourceShapeImpl(config, context);
|
|
345
|
+
const roots = absoluteRoots(config.root, [...config.backend.features, ...config.backend.modules]);
|
|
346
|
+
const localFiles = new Set(context.files.map(file => canonical(file.fileName)));
|
|
347
|
+
const frameworkByChecker = new Map();
|
|
348
|
+
const namingReasons = [];
|
|
349
|
+
// The shared shape walk dispatches Query and Mutation; subscriptions use the same naming relation here.
|
|
350
|
+
for (const sourceFile of context.files) {
|
|
351
|
+
const fileName = canonical(sourceFile.fileName);
|
|
352
|
+
const configuredRoot = locatedRoot(roots, fileName);
|
|
353
|
+
const owner = config.hfs?.ownerOf?.(relativePath(config.root, fileName));
|
|
354
|
+
const slot = owner ? config.hfs.slot(owner.slot) : null;
|
|
355
|
+
if (!configuredRoot && !(slot?.tier === 'feature' && slot.profiles?.includes('be'))) continue;
|
|
356
|
+
const checker = context.checkerFor(fileName);
|
|
357
|
+
if (!frameworkByChecker.has(checker)) frameworkByChecker.set(checker, frameworkTargets(config, context, checker, localFiles));
|
|
358
|
+
const framework = frameworkByChecker.get(checker);
|
|
359
|
+
inspectSubscriptionSource({ config, context, sourceFile, checker, framework, configuredRoot, namingReasons, violations: result.violations });
|
|
360
|
+
}
|
|
361
|
+
if (namingReasons.length) result.coverage.naming = {
|
|
362
|
+
status: 'unavailable', reason: 'one or more backend source naming relations are not statically proved',
|
|
363
|
+
details: [...new Set([...(result.coverage.naming.details ?? []), ...namingReasons])].sort(byCodeUnit),
|
|
364
|
+
};
|
|
365
|
+
return result;
|
|
321
366
|
}
|
|
322
367
|
export { SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID };
|
|
@@ -1,8 +1,10 @@
|
|
|
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
3
|
import { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
4
|
+
import { accessName as memberName, hasAnyFlag } from '../../lib/ts-ast.mjs';
|
|
5
|
+
export { accessName as memberName } from '../../lib/ts-ast.mjs';
|
|
4
6
|
|
|
5
|
-
export const SUPABASE_MODULES =
|
|
7
|
+
export const SUPABASE_MODULES = Object.freeze({ has: name => typeof name === 'string' && name.startsWith('@supabase/') });
|
|
6
8
|
|
|
7
9
|
export const moduleNameOf = (ts, node) => {
|
|
8
10
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
@@ -17,7 +19,7 @@ export const importedFrom = (kit, checker, node, modules, names = null) => {
|
|
|
17
19
|
return Boolean(binding && modules.has(binding.module) && (names === null || names.has(binding.name)));
|
|
18
20
|
};
|
|
19
21
|
|
|
20
|
-
const declarationFromSupabase = (declaration) => /(?:^|\/)node_modules\/@supabase\/(
|
|
22
|
+
const declarationFromSupabase = (declaration) => /(?:^|\/)node_modules\/@supabase\/[^/]+(?:\/|$)/u
|
|
21
23
|
.test(String(declaration?.getSourceFile?.().fileName ?? '').replaceAll('\\', '/'));
|
|
22
24
|
|
|
23
25
|
const typeFromSupabase = (checker, node) => {
|
|
@@ -50,23 +52,19 @@ const isSupabaseValue = (kit, checker, node) => {
|
|
|
50
52
|
return typeFromSupabase(checker, current);
|
|
51
53
|
};
|
|
52
54
|
|
|
53
|
-
export const callName = (ts, call) => ts.isCallExpression(call)
|
|
55
|
+
export const callName = (ts, call) => ts.isCallExpression(call) ? memberName(ts, call.expression) : null;
|
|
54
56
|
|
|
55
57
|
export const chainParts = (ts, expression) => {
|
|
56
58
|
const names = [];
|
|
57
59
|
let current = unwrap(ts, expression);
|
|
58
60
|
while (current) {
|
|
59
61
|
if (ts.isCallExpression(current)) {
|
|
60
|
-
if (ts.isPropertyAccessExpression(current.expression)) {
|
|
61
|
-
names.unshift(current.expression.name.text);
|
|
62
|
-
current = unwrap(ts, current.expression.expression);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
62
|
current = unwrap(ts, current.expression);
|
|
66
63
|
continue;
|
|
67
64
|
}
|
|
68
|
-
if (ts.isPropertyAccessExpression(current)) {
|
|
69
|
-
|
|
65
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
66
|
+
const name = memberName(ts, current);
|
|
67
|
+
if (name) names.unshift(name);
|
|
70
68
|
current = unwrap(ts, current.expression);
|
|
71
69
|
continue;
|
|
72
70
|
}
|
|
@@ -76,7 +74,47 @@ export const chainParts = (ts, expression) => {
|
|
|
76
74
|
};
|
|
77
75
|
|
|
78
76
|
export const isSupabaseCall = (kit, checker, node) => kit.ts.isCallExpression(node)
|
|
79
|
-
&& (signatureFromSupabase(checker, node) || isSupabaseValue(kit, checker,
|
|
77
|
+
&& (signatureFromSupabase(checker, node) || isSupabaseValue(kit, checker,
|
|
78
|
+
kit.ts.isPropertyAccessExpression(node.expression) || kit.ts.isElementAccessExpression(node.expression) ? node.expression.expression : node.expression));
|
|
79
|
+
|
|
80
|
+
const authStorageValue = (kit, checker, expression, seen = new Set()) => {
|
|
81
|
+
const current = unwrap(kit.ts, expression);
|
|
82
|
+
if (!current || seen.has(current)) return false;
|
|
83
|
+
seen.add(current);
|
|
84
|
+
if (['auth', 'storage'].includes(memberName(kit.ts, current)) && isSupabaseValue(kit, checker, current.expression)) return true;
|
|
85
|
+
if (checker.getTypeAtLocation(current).symbol?.declarations?.some(declaration =>
|
|
86
|
+
/\/node_modules\/@supabase\/(?:storage-js|auth-js)\//u.test(String(declaration.getSourceFile().fileName).replaceAll('\\', '/')))) return true;
|
|
87
|
+
if (!kit.ts.isIdentifier(current)) return false;
|
|
88
|
+
return kit.declarationsOf(checker, current).some(declaration =>
|
|
89
|
+
(kit.ts.isVariableDeclaration(declaration) || kit.ts.isPropertyDeclaration(declaration)) && declaration.initializer
|
|
90
|
+
&& authStorageValue(kit, checker, declaration.initializer, seen));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const chainAccesses = (ts, node) => {
|
|
94
|
+
const accesses = [];
|
|
95
|
+
let current = unwrap(ts, node);
|
|
96
|
+
while (current) {
|
|
97
|
+
if (ts.isCallExpression(current)) current = unwrap(ts, current.expression);
|
|
98
|
+
else if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
99
|
+
accesses.unshift(current);
|
|
100
|
+
current = unwrap(ts, current.expression);
|
|
101
|
+
} else break;
|
|
102
|
+
}
|
|
103
|
+
return accesses;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The first forbidden SDK access in a call chain, anchored once even when more methods follow it. */
|
|
107
|
+
export const supabaseDataAccess = (kit, checker, node, members, { storageAllowed = false } = {}) => {
|
|
108
|
+
for (const access of chainAccesses(kit.ts, node)) {
|
|
109
|
+
const name = memberName(kit.ts, access);
|
|
110
|
+
if (storageAllowed && (name === 'storage' || name === 'auth')) return null;
|
|
111
|
+
if (members.has(name) && isSupabaseValue(kit, checker, access.expression)) {
|
|
112
|
+
if (storageAllowed && authStorageValue(kit, checker, access.expression)) return null;
|
|
113
|
+
return { name, node: access };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
80
118
|
|
|
81
119
|
export const isDatabaseType = (ts, node) => {
|
|
82
120
|
if (!node) return false;
|
|
@@ -106,3 +144,165 @@ export const contextTypesClient = (kit, checker, call) => {
|
|
|
106
144
|
export const reportAt = (violations, kit, ruleId, file, node, message, extra = {}) => {
|
|
107
145
|
violations.push({ ruleId, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
108
146
|
};
|
|
147
|
+
|
|
148
|
+
const declarationsOf = (ts, checker, node) => {
|
|
149
|
+
const target = ts.isPropertyAccessExpression(node) ? node.name : node;
|
|
150
|
+
// A shorthand property (`issuer,`) names the variable it reads, not the property it creates.
|
|
151
|
+
let symbol = ts.isShorthandPropertyAssignment(target.parent ?? {}) && target.parent.name === target
|
|
152
|
+
? checker.getShorthandAssignmentValueSymbol(target.parent)
|
|
153
|
+
: checker.getSymbolAtLocation(target);
|
|
154
|
+
if (symbol && hasAnyFlag(symbol.flags, ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol);
|
|
155
|
+
return symbol?.declarations ?? [];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const configType = (checker, node) => {
|
|
159
|
+
const type = checker.getTypeAtLocation(node);
|
|
160
|
+
return [type.symbol, type.aliasSymbol].some(symbol => symbol?.declarations?.some(declaration =>
|
|
161
|
+
/(?:\.config|\.options|\.env)\.ts$|\/modules\/config\/|\/node_modules\/@nestjs\/config\//u.test(String(declaration.getSourceFile().fileName).replaceAll('\\', '/'))
|
|
162
|
+
|| /(?:Config|Options|Env)$/u.test(symbol.name)));
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Trace issuer construction through configuration values and local URL/string transformations. */
|
|
166
|
+
const issuerFromConfig = (ts, checker, expression, seen = new Set()) => {
|
|
167
|
+
const node = unwrap(ts, expression);
|
|
168
|
+
if (!node || seen.has(node)) return false;
|
|
169
|
+
const next = new Set(seen).add(node);
|
|
170
|
+
if (ts.isIdentifier(node)) {
|
|
171
|
+
return declarationsOf(ts, checker, node).some(declaration => {
|
|
172
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) return issuerFromConfig(ts, checker, declaration.initializer, next);
|
|
173
|
+
if (ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration)) return configType(checker, node);
|
|
174
|
+
return /(?:\.config|\.env)\.ts$|\/modules\/config\//u.test(String(declaration.getSourceFile().fileName).replaceAll('\\', '/'));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
178
|
+
return issuerFromConfig(ts, checker, node.expression, next) || declarationsOf(ts, checker, node).some(declaration =>
|
|
179
|
+
(ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration)) && !declaration.initializer && configType(checker, node));
|
|
180
|
+
}
|
|
181
|
+
if (ts.isTemplateExpression(node)) return node.templateSpans.some(span => issuerFromConfig(ts, checker, span.expression, next));
|
|
182
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
183
|
+
return issuerFromConfig(ts, checker, node.left, next) || issuerFromConfig(ts, checker, node.right, next);
|
|
184
|
+
}
|
|
185
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
186
|
+
return issuerFromConfig(ts, checker, node.expression, next) || node.arguments?.some(argument => issuerFromConfig(ts, checker, argument, next));
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const objectValue = (ts, checker, expression) => {
|
|
192
|
+
const node = unwrap(ts, expression);
|
|
193
|
+
if (!node) return null;
|
|
194
|
+
if (ts.isObjectLiteralExpression(node)) return node;
|
|
195
|
+
if (!ts.isIdentifier(node)) return null;
|
|
196
|
+
return declarationsOf(ts, checker, node).find(declaration => ts.isVariableDeclaration(declaration)
|
|
197
|
+
&& declaration.initializer && ts.isObjectLiteralExpression(unwrap(ts, declaration.initializer)))?.initializer ?? null;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const literalString = (ts, checker, expression) => {
|
|
201
|
+
const node = unwrap(ts, expression);
|
|
202
|
+
if (!node) return null;
|
|
203
|
+
if (ts.isStringLiteralLike(node)) return node.text;
|
|
204
|
+
const type = checker.getTypeAtLocation(node);
|
|
205
|
+
return type.isStringLiteral?.() ? type.value : null;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** A role read must belong to the payload produced by this verification, including destructuring aliases. */
|
|
209
|
+
const verifiedPayload = (ts, checker, expression, call, seen = new Set()) => {
|
|
210
|
+
const node = unwrap(ts, expression);
|
|
211
|
+
if (!node || seen.has(node)) return false;
|
|
212
|
+
const next = new Set(seen).add(node);
|
|
213
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && memberName(ts, node) === 'payload') {
|
|
214
|
+
const value = unwrap(ts, node.expression);
|
|
215
|
+
if (value === call) return true;
|
|
216
|
+
if (ts.isIdentifier(value)) return declarationsOf(ts, checker, value).some(declaration =>
|
|
217
|
+
ts.isVariableDeclaration(declaration) && unwrap(ts, declaration.initializer) === call);
|
|
218
|
+
}
|
|
219
|
+
if (!ts.isIdentifier(node)) return false;
|
|
220
|
+
return declarationsOf(ts, checker, node).some(declaration => {
|
|
221
|
+
if (ts.isVariableDeclaration(declaration)) return declaration.initializer && verifiedPayload(ts, checker, declaration.initializer, call, next);
|
|
222
|
+
if (!ts.isBindingElement(declaration) || (declaration.propertyName ?? declaration.name).getText() !== 'payload') return false;
|
|
223
|
+
return unwrap(ts, declaration.parent.parent.initializer) === call;
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const rejects = (ts, statement) => {
|
|
228
|
+
if (ts.isBlock(statement)) {
|
|
229
|
+
const exit = statement.statements.find(item => ts.isThrowStatement(item) || ts.isReturnStatement(item));
|
|
230
|
+
return Boolean(exit && rejects(ts, exit));
|
|
231
|
+
}
|
|
232
|
+
if (ts.isThrowStatement(statement)) return true;
|
|
233
|
+
if (!ts.isReturnStatement(statement)) return false;
|
|
234
|
+
const value = statement.expression && unwrap(ts, statement.expression);
|
|
235
|
+
return !value || value.kind === ts.SyntaxKind.NullKeyword || value.kind === ts.SyntaxKind.FalseKeyword
|
|
236
|
+
|| (ts.isCallExpression(value) && /^(?:refused|err|fail|failure)$/u.test(value.expression.getText()));
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const roleCondition = (ts, checker, condition, call, equality = false) => {
|
|
240
|
+
const node = unwrap(ts, condition);
|
|
241
|
+
if (!node || !ts.isBinaryExpression(node)) return false;
|
|
242
|
+
const combined = equality ? ts.SyntaxKind.AmpersandAmpersandToken : ts.SyntaxKind.BarBarToken;
|
|
243
|
+
if (node.operatorToken.kind === combined) return roleCondition(ts, checker, node.left, call, equality) || roleCondition(ts, checker, node.right, call, equality);
|
|
244
|
+
const operators = equality ? [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken]
|
|
245
|
+
: [ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken];
|
|
246
|
+
if (!operators.includes(node.operatorToken.kind)) return false;
|
|
247
|
+
const role = expression => (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression))
|
|
248
|
+
&& memberName(ts, expression) === 'role' && verifiedPayload(ts, checker, expression.expression, call);
|
|
249
|
+
return (role(node.left) && literalString(ts, checker, node.right) === 'authenticated')
|
|
250
|
+
|| (role(node.right) && literalString(ts, checker, node.left) === 'authenticated');
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const returnsPrincipal = (ts, statement) => {
|
|
254
|
+
let accepted = false;
|
|
255
|
+
const visit = node => {
|
|
256
|
+
if (ts.isFunctionLike(node)) return;
|
|
257
|
+
if (ts.isReturnStatement(node) && !rejects(ts, node)) accepted = true;
|
|
258
|
+
ts.forEachChild(node, visit);
|
|
259
|
+
};
|
|
260
|
+
visit(statement);
|
|
261
|
+
return accepted;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const statementsAfterVerify = (ts, call) => {
|
|
265
|
+
let statement = call;
|
|
266
|
+
while (statement.parent && !ts.isBlock(statement.parent)) statement = statement.parent;
|
|
267
|
+
const block = statement.parent;
|
|
268
|
+
if (!block || !ts.isBlock(block)) return [];
|
|
269
|
+
return block.statements.slice(block.statements.indexOf(statement) + 1);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const rejectsRoleGuard = (ts, checker, statement, call) => {
|
|
273
|
+
if (!ts.isIfStatement(statement)) return false;
|
|
274
|
+
if (roleCondition(ts, checker, statement.expression, call) && rejects(ts, statement.thenStatement)) return true;
|
|
275
|
+
return Boolean(statement.elseStatement && roleCondition(ts, checker, statement.expression, call, true) && rejects(ts, statement.elseStatement));
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const rejectsOtherRoles = (ts, checker, call) => {
|
|
279
|
+
for (const item of statementsAfterVerify(ts, call)) {
|
|
280
|
+
if (ts.isReturnStatement(item) || ts.isThrowStatement(item)) return false;
|
|
281
|
+
if (rejectsRoleGuard(ts, checker, item, call)) return true;
|
|
282
|
+
if (returnsPrincipal(ts, item)) return false;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** R260 is shared by the architecture machine and the published typed ESLint rule. */
|
|
288
|
+
export const jwtPolicyProblems = (ts, checker, call) => {
|
|
289
|
+
const problems = [];
|
|
290
|
+
const options = objectValue(ts, checker, call.arguments[2]);
|
|
291
|
+
const property = name => options?.properties.findLast(entry =>
|
|
292
|
+
(ts.isPropertyAssignment(entry) || ts.isShorthandPropertyAssignment(entry))
|
|
293
|
+
&& (ts.isIdentifier(entry.name) || ts.isStringLiteralLike(entry.name)) && entry.name.text === name);
|
|
294
|
+
const value = name => {
|
|
295
|
+
const entry = property(name);
|
|
296
|
+
return entry && unwrap(ts, ts.isPropertyAssignment(entry) ? entry.initializer : entry.name);
|
|
297
|
+
};
|
|
298
|
+
const algorithms = value('algorithms');
|
|
299
|
+
const secure = new Set(['ES256', 'RS256', 'EdDSA']);
|
|
300
|
+
if (!algorithms || !ts.isArrayLiteralExpression(algorithms) || !algorithms.elements.length
|
|
301
|
+
|| algorithms.elements.some(item => !ts.isStringLiteralLike(item) || !secure.has(item.text))
|
|
302
|
+
|| options?.properties.some(entry => ts.isSpreadAssignment(entry))) {
|
|
303
|
+
problems.push('BE_JWT_ALG_PINNED: algorithms must be a non-empty literal subset of ES256, RS256 and EdDSA.');
|
|
304
|
+
}
|
|
305
|
+
if (!issuerFromConfig(ts, checker, value('issuer'))) problems.push('BE_JWT_ALG_PINNED: issuer must derive from typed configuration.');
|
|
306
|
+
if (!rejectsOtherRoles(ts, checker, call)) problems.push('BE_JWT_ALG_PINNED: the verified principal must reject every role other than authenticated before returning it.');
|
|
307
|
+
return problems;
|
|
308
|
+
};
|
|
@@ -3,7 +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 '
|
|
6
|
+
import { accessName } from '../../lib/ts-ast.mjs';
|
|
7
7
|
|
|
8
8
|
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
9
9
|
const BE_JWT_VERIFIED = 'BE_SUPABASE_JWT_VERIFIED';
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { machineKit } from './machine-ast.mjs';
|
|
2
|
-
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, supabaseClientType, SUPABASE_MODULES } from './supabase-ast.mjs';
|
|
2
|
+
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, jwtPolicyProblems, moduleNameOf, reportAt, supabaseClientType, supabaseDataAccess, SUPABASE_MODULES } from './supabase-ast.mjs';
|
|
3
3
|
import { checkBackendJwt } from './supabase-be.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* L10-L16, the slot-driven Supabase machine. On the back end Supabase
|
|
6
|
+
* L10-L16, the slot-driven Supabase machine. On the back end Supabase provides Auth/Storage with one owner,
|
|
7
7
|
* `be.integrations.supabase`. On the front end Supabase is auth only (owner ruling 2026-10-13): `fe.modules.auth` is the one
|
|
8
8
|
* importer of `@supabase/*` and calls only the auth API; data, storage, rpc and realtime go through the back end (modules/api).
|
|
9
9
|
* The checks below recognise provider calls and types by their imported package declarations, not by local variable or type names.
|
|
10
10
|
*/
|
|
11
11
|
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze(['FE_SUPABASE_CLIENT_OWNER', 'FE_SERVICE_ROLE_FORBIDDEN']);
|
|
12
|
-
export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED']);
|
|
12
|
+
export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED', 'BE_JWT_ALG_PINNED']);
|
|
13
13
|
|
|
14
14
|
const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
|
|
15
15
|
const BE_CLIENT_OWNER = 'BE_SUPABASE_CLIENT_OWNER';
|
|
@@ -19,7 +19,7 @@ const FE_AUTH_SLOT = 'fe.modules.auth';
|
|
|
19
19
|
/** The front-end provider slot: enabled exactly when a connection declares provider supabase. */
|
|
20
20
|
const FE_SUPABASE_PROVIDER_SLOT = 'fe.route.callback';
|
|
21
21
|
const FE_CONFIG_SLOT = 'fe.modules.config';
|
|
22
|
-
const
|
|
22
|
+
const BE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'functions', 'channel', 'realtime', 'getChannels', 'removeChannel', 'removeAllChannels']);
|
|
23
23
|
/** The members of a Supabase client that read or write data, files or channels: never called from the front end. */
|
|
24
24
|
const FE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'storage', 'functions', 'realtime', 'channel', 'getChannels', 'removeChannel', 'removeAllChannels']);
|
|
25
25
|
const supabaseSlotEnabled = (input, slotId) => {
|
|
@@ -55,12 +55,21 @@ function inspectClientFactoryCall(scope, node) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function inspectProviderCall(scope, node) {
|
|
58
|
-
const { kit, checker, file, slots, ruleId, violations, counters } = scope;
|
|
58
|
+
const { kit, checker, file, slots, ruleId, violations, counters, reportedData } = scope;
|
|
59
59
|
const { ts } = kit;
|
|
60
|
+
const data = supabaseDataAccess(kit, checker, node, BE_DATA_MEMBERS, { storageAllowed: true });
|
|
61
|
+
if (data) {
|
|
62
|
+
if (!reportedData.has(data.node.pos)) {
|
|
63
|
+
reportedData.add(data.node.pos);
|
|
64
|
+
reportAt(violations, kit, ruleId, file, data.node, `A Supabase .${data.name} data call is made from ${file.slot ?? 'an unowned path'}; the back-end SDK is Auth/Storage only, including inside ${BE_SUPABASE_SLOT}. Data uses EntityManager as app_be.`, { member: data.name, slot: file.slot ?? null });
|
|
65
|
+
}
|
|
66
|
+
if (ts.isCallExpression(node)) counters.calls += 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
60
69
|
if (!isSupabaseCall(kit, checker, node)) return;
|
|
61
70
|
const properties = chainParts(ts, node);
|
|
62
71
|
const method = callName(ts, node);
|
|
63
|
-
if (!
|
|
72
|
+
if (!properties.includes('auth') && !properties.includes('storage')) return;
|
|
64
73
|
counters.calls += 1;
|
|
65
74
|
if (!slots.has(file.slot)) reportAt(violations, kit, ruleId, file, node, `A Supabase ${method ?? 'client'} call is made from ${file.slot ?? 'an unowned path'}; database, auth and storage calls belong only to ${[...slots].join(' or ')}.`, { method, slot: file.slot ?? null });
|
|
66
75
|
}
|
|
@@ -68,8 +77,9 @@ function inspectProviderCall(scope, node) {
|
|
|
68
77
|
function inspectOwnershipNode(scope, node) {
|
|
69
78
|
inspectSupabaseImport(scope, node);
|
|
70
79
|
inspectClientTypeReference(scope, node);
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
const { ts } = scope.kit;
|
|
81
|
+
if (!ts.isCallExpression(node) && !ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return;
|
|
82
|
+
if (ts.isCallExpression(node)) inspectClientFactoryCall(scope, node);
|
|
73
83
|
inspectProviderCall(scope, node);
|
|
74
84
|
}
|
|
75
85
|
|
|
@@ -79,7 +89,7 @@ function checkClientOwnership(input, { slots, ruleId }) {
|
|
|
79
89
|
const counters = { imports: 0, clients: 0, calls: 0 };
|
|
80
90
|
for (const file of input.graph.files.values()) {
|
|
81
91
|
const checker = kit.checkerOf(file.sourceFile);
|
|
82
|
-
const scope = { kit, checker, file, slots, ruleId, violations, counters };
|
|
92
|
+
const scope = { kit, checker, file, slots, ruleId, violations, counters, reportedData: new Set() };
|
|
83
93
|
kit.walk(file.sourceFile, node => inspectOwnershipNode(scope, node));
|
|
84
94
|
}
|
|
85
95
|
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [...slots] } };
|
|
@@ -138,7 +148,7 @@ function checkFrontendSecretNames(input) {
|
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
function inspectFrontendNode(scope, node) {
|
|
141
|
-
const { kit, checker, file, violations, counters } = scope;
|
|
151
|
+
const { kit, checker, file, violations, counters, reportedData } = scope;
|
|
142
152
|
const { ts } = kit;
|
|
143
153
|
const moduleName = moduleNameOf(ts, node);
|
|
144
154
|
if (moduleName?.startsWith('@supabase/')) {
|
|
@@ -146,14 +156,19 @@ function inspectFrontendNode(scope, node) {
|
|
|
146
156
|
if (file.slot !== FE_AUTH_SLOT) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `${moduleName} is imported by ${file.rel}, whose slot is ${file.slot ?? 'unowned'}; on the front end Supabase is auth only and ${FE_AUTH_SLOT} (modules/auth) is its one importer.`, { module: moduleName, slot: file.slot ?? null });
|
|
147
157
|
return;
|
|
148
158
|
}
|
|
149
|
-
if (!ts.isCallExpression(node)
|
|
159
|
+
if (!ts.isCallExpression(node) && !ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return;
|
|
160
|
+
const data = supabaseDataAccess(kit, checker, node, FE_DATA_MEMBERS);
|
|
161
|
+
if (data) {
|
|
162
|
+
if (!reportedData.has(data.node.pos)) {
|
|
163
|
+
reportedData.add(data.node.pos);
|
|
164
|
+
reportAt(violations, kit, FE_CLIENT_OWNER, file, data.node, `A Supabase .${data.name} call is made from the front end; Supabase on the front end is auth only - read and write data, files and realtime through the back end (a modules/api GraphQL operation, an SWR hook, hooks/socketio).`, { member: data.name, slot: file.slot ?? null });
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!isSupabaseCall(kit, checker, node)) return;
|
|
150
169
|
counters.calls += 1;
|
|
151
|
-
const member = callName(ts, node);
|
|
152
170
|
const parts = chainParts(ts, node);
|
|
153
|
-
|
|
154
|
-
if (FE_DATA_MEMBERS.has(member)) data = parts.find(name => FE_DATA_MEMBERS.has(name)) ?? member;
|
|
155
|
-
if (data) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase .${data} call is made from the front end; Supabase on the front end is auth only - read and write data, files and realtime through the back end (a modules/api GraphQL operation, an SWR hook, hooks/socketio).`, { member: data, slot: file.slot ?? null });
|
|
156
|
-
else if (file.slot !== FE_AUTH_SLOT && parts.includes('auth')) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase auth call is made from ${file.slot ?? 'an unowned path'}; the auth client is called only inside ${FE_AUTH_SLOT}, which exports the session and the access token.`, { slot: file.slot ?? null });
|
|
171
|
+
if (file.slot !== FE_AUTH_SLOT && parts.includes('auth')) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase auth call is made from ${file.slot ?? 'an unowned path'}; the auth client is called only inside ${FE_AUTH_SLOT}, which exports the session and the access token.`, { slot: file.slot ?? null });
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
function checkFrontendClientOwner(input) {
|
|
@@ -161,15 +176,18 @@ function checkFrontendClientOwner(input) {
|
|
|
161
176
|
const violations = [];
|
|
162
177
|
const counters = { imports: 0, calls: 0 };
|
|
163
178
|
for (const file of input.graph.files.values()) {
|
|
164
|
-
const scope = { kit, checker: kit.checkerOf(file.sourceFile), file, violations, counters };
|
|
179
|
+
const scope = { kit, checker: kit.checkerOf(file.sourceFile), file, violations, counters, reportedData: new Set() };
|
|
165
180
|
kit.walk(file.sourceFile, node => inspectFrontendNode(scope, node));
|
|
166
181
|
}
|
|
167
182
|
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [FE_AUTH_SLOT] } };
|
|
168
183
|
}
|
|
169
184
|
|
|
170
185
|
export function checkFrontendSupabase(input) {
|
|
171
|
-
if (!supabaseSlotEnabled(input, FE_SUPABASE_PROVIDER_SLOT)) return { violations: [], coverage: { status: 'not-applicable', reason: 'no connection declares provider supabase' } };
|
|
172
186
|
const owner = checkFrontendClientOwner(input);
|
|
187
|
+
if (!supabaseSlotEnabled(input, FE_SUPABASE_PROVIDER_SLOT)) return {
|
|
188
|
+
violations: owner.violations,
|
|
189
|
+
coverage: { status: owner.coverage.imports || owner.coverage.calls || owner.violations.length ? 'checked' : 'not-applicable', owner: owner.coverage, reason: 'no connection declares provider supabase; SDK ownership is still checked' },
|
|
190
|
+
};
|
|
173
191
|
const secrets = checkFrontendSecretNames(input);
|
|
174
192
|
return {
|
|
175
193
|
violations: [...owner.violations, ...secrets.violations],
|
|
@@ -178,11 +196,31 @@ export function checkFrontendSupabase(input) {
|
|
|
178
196
|
}
|
|
179
197
|
|
|
180
198
|
export function checkBackendSupabase(input) {
|
|
181
|
-
if (!supabaseSlotEnabled(input, BE_SUPABASE_SLOT)) return { violations: [], coverage: { status: 'not-applicable', reason: 'the Supabase back-end integration slot is not enabled' } };
|
|
182
199
|
const owner = checkClientOwnership(input, { slots: new Set([BE_SUPABASE_SLOT]), ruleId: BE_CLIENT_OWNER });
|
|
200
|
+
if (!supabaseSlotEnabled(input, BE_SUPABASE_SLOT)) return {
|
|
201
|
+
violations: owner.violations,
|
|
202
|
+
coverage: { status: owner.coverage.imports || owner.coverage.calls || owner.violations.length ? 'checked' : 'not-applicable', owner: owner.coverage, reason: 'the Supabase integration slot is disabled; SDK ownership is still checked' },
|
|
203
|
+
};
|
|
183
204
|
const jwt = checkBackendJwt(input);
|
|
205
|
+
const policy = checkJwtPolicy(input);
|
|
184
206
|
return {
|
|
185
|
-
violations: [...owner.violations, ...jwt.violations],
|
|
186
|
-
coverage: { status: 'checked', owner: owner.coverage, jwt: jwt.coverage },
|
|
207
|
+
violations: [...owner.violations, ...jwt.violations, ...policy.violations],
|
|
208
|
+
coverage: { status: 'checked', owner: owner.coverage, jwt: jwt.coverage, jwtPolicy: policy.coverage },
|
|
187
209
|
};
|
|
188
210
|
}
|
|
211
|
+
|
|
212
|
+
export function checkJwtPolicy(input) {
|
|
213
|
+
const kit = machineKit(input);
|
|
214
|
+
const violations = [];
|
|
215
|
+
let verifyCalls = 0;
|
|
216
|
+
for (const file of input.graph.files.values()) {
|
|
217
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
218
|
+
kit.walk(file.sourceFile, node => {
|
|
219
|
+
if (!kit.ts.isCallExpression(node) || !kit.isImportOf(checker, node.expression, 'jwtVerify', 'jose')) return;
|
|
220
|
+
verifyCalls += 1;
|
|
221
|
+
const problems = jwtPolicyProblems(kit.ts, checker, node);
|
|
222
|
+
if (problems.length) reportAt(violations, kit, 'BE_JWT_ALG_PINNED', file, node, problems.join(' '));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return { violations, coverage: { status: 'checked', verifyCalls } };
|
|
226
|
+
}
|
|
@@ -64,7 +64,8 @@ export const LINT_ENFORCERS = Object.freeze([
|
|
|
64
64
|
enforcer('public-contract-form', BE, ['BE_PUBLIC_CONTRACT_FORM'], 'A public signature is typed by a named contract, never inline or untyped.'),
|
|
65
65
|
enforcer('frontend-source-layout', FE, ['FE_SOURCE_LAYOUT_INVALID'], 'A front-end source file sits in a tier folder.'),
|
|
66
66
|
enforcer('component-purity', FE, ['FE_COMPONENT_WORLD_OWNERSHIP', 'FE_WORLD_OWNER_RENDER_BOUNDARY', 'FE_CONNECTED_BLOCK_RENDER_PAIR', 'FE_PURE_REACHES_DATA', 'FE_PURE_WORLD_HOOK', 'FE_PURE_WORLD_IMPORT'], 'A pure component reaches no data or world state; its connected entry owns them.'),
|
|
67
|
-
|
|
67
|
+
// Package-manifest contract findings stay with app check; only source import bypasses belong to lint.
|
|
68
|
+
enforcer('grammar-entry', FE, ['ARCH_GRAMMAR_EXPORT_BYPASS'], 'Vendor grammar is reached only through its owner entry.'),
|
|
68
69
|
enforcer('i18n-keys', FE, ['FE_I18N_KEYS'], 'Translation keys read by source and keys held by catalogs agree both ways.'),
|
|
69
70
|
// The per-path judgements of the slot manifest (scripts/hfs/path-findings.mjs), on a tracked TypeScript file.
|
|
70
71
|
enforcer('slot-undeclared', BOTH, ['HFS_SLOT_UNDECLARED', 'HFS_SLOT_AMBIGUOUS', 'HFS_SLOT_NOT_ENABLED'], 'A TypeScript file is owned by exactly one slot of the repository.', { origin: 'repo' }),
|
|
@@ -7,12 +7,12 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
7
7
|
* is the one direction with its own code (BE_FEATURE_IMPORTS_FEATURE, R28), never BE_TIER_DIRECTION, and a feature of one trigger
|
|
8
8
|
* kind (slot field `trigger`: api, webhooks, realtime, saga, reactors, jobs, cli) importing a feature of another is
|
|
9
9
|
* BE_KIND_ISOLATION, whose fix is the event bus,
|
|
10
|
-
* an app never imports another app (FE_APP_ISOLATION), and a component layer imports only the layers after it;
|
|
10
|
+
* an app never imports another app (BE_SERVICE_ISOLATION / FE_APP_ISOLATION), and a component layer imports only the layers after it;
|
|
11
11
|
* 2. owner cycles: a strongly connected component of the owner graph, type-only imports included (ARCH_OWNER_CYCLE),
|
|
12
12
|
* reported with the cycle path and the import that closes each hop.
|
|
13
13
|
* Importing inside one owner is always allowed; the public-entry rule stays with owners.mjs (ARCH_OWNER_EXPORT_BYPASS).
|
|
14
14
|
*/
|
|
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'];
|
|
15
|
+
export const TIER_RULE_IDS = ['BE_TIER_DIRECTION', 'BE_FEATURE_IMPORTS_FEATURE', 'BE_KIND_ISOLATION', 'BE_SERVICE_ISOLATION', 'FE_TIER_DIRECTION', 'FE_APP_ISOLATION', 'ARCH_OWNER_CYCLE'];
|
|
16
16
|
|
|
17
17
|
function tierDirectionText({ fromTier, toTier, mayImport, kindRestriction, fromKind, toKind }) {
|
|
18
18
|
if (!kindRestriction) return `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`;
|
|
@@ -118,7 +118,7 @@ function reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, vio
|
|
|
118
118
|
let ruleId = tierRule;
|
|
119
119
|
if (featureToFeature) ruleId = 'BE_FEATURE_IMPORTS_FEATURE';
|
|
120
120
|
if (crossKind) ruleId = 'BE_KIND_ISOLATION';
|
|
121
|
-
if (verdict.reason === 'crossApp') ruleId = 'FE_APP_ISOLATION';
|
|
121
|
+
if (verdict.reason === 'crossApp') ruleId = graph.profile === 'be' ? 'BE_SERVICE_ISOLATION' : 'FE_APP_ISOLATION';
|
|
122
122
|
const typeOnly = edge.runtime ? '' : ' (type-only imports count)';
|
|
123
123
|
const message = crossKind
|
|
124
124
|
? `${edge.from} -> ${edge.to}: a ${fromKind} feature imports a ${toKind} feature; no kind imports another, every cross-kind call is an event published with eventBus.publish(event, tx) from a domain service${typeOnly}.`
|
|
@@ -5,6 +5,7 @@ import { constructorDependencies } from './constructor-deps.mjs';
|
|
|
5
5
|
import { treeOf } from './required-files.mjs';
|
|
6
6
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
7
7
|
import { importsOf } from '../../lib/ts-analysis.mjs';
|
|
8
|
+
import { checkFunctionSubject, functionSubjectExports, keyOf as literalKey, valueObjectSubjects } from './function-spec-providers.mjs';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
|
|
@@ -15,7 +16,10 @@ import { importsOf } from '../../lib/ts-analysis.mjs';
|
|
|
15
16
|
* outside the production program, so it is read from disk). A parameter decorated with a custom `Inject<Thing>()` provides
|
|
16
17
|
* the token that decorator's `injector<T>(TOKEN)` names; an undecorated parameter of a class type provides that class (see
|
|
17
18
|
* constructor-deps.mjs). Providers are matched by the exported name of the imported binding (`import { CACHE as C }` is
|
|
18
|
-
* CACHE), never by text. A provider is a class, or `{ provide: TOKEN, useValue: double }` and nothing else.
|
|
19
|
+
* CACHE), never by text. A provider is a class, or `{ provide: TOKEN, useValue: double }` and nothing else. A sibling
|
|
20
|
+
* containing only exported functions instead provides its complete real function object and exercises it through module.get;
|
|
21
|
+
* the constants, enums, re-exports and overload signatures beside those functions do not count. A value-object subject (an
|
|
22
|
+
* undecorated class whose constructor parameters carry no injection token) is built with `new`, Nest never constructs it.
|
|
19
23
|
*
|
|
20
24
|
* Findings: an extra provider, a missing provider, a service that is not the first provider, a dependency whose token cannot
|
|
21
25
|
* be resolved, a spec with no createTestingModule providers array, and a provider that is neither a class nor
|
|
@@ -37,7 +41,6 @@ function testingModules(ts, sourceFile) {
|
|
|
37
41
|
return calls;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
const literalKey = (ts, property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
|
|
41
44
|
|
|
42
45
|
function providerList(ts, call, rel, report) {
|
|
43
46
|
const options = call.arguments[0];
|
|
@@ -97,6 +100,8 @@ function checkDependencies(kit, rel, service, serviceFile, list, provided, repor
|
|
|
97
100
|
function checkTestingModule({ ts, kit, rel, serviceRel, serviceFile, imports, call, report }) {
|
|
98
101
|
const list = providerList(ts, call, rel, report);
|
|
99
102
|
if (!list) return;
|
|
103
|
+
const names = functionSubjectExports(ts, serviceFile.sourceFile);
|
|
104
|
+
if (names) return checkFunctionSubject({ ts, rel, serviceRel, imports, call, list, names, report });
|
|
100
105
|
const nameOf = identifier => imports.get(identifier.text)?.name ?? identifier.text;
|
|
101
106
|
const { provided, readable } = readProviders(ts, list, rel, nameOf, report);
|
|
102
107
|
if (!readable) return;
|
|
@@ -115,6 +120,7 @@ function checkServiceSpec({ config, graph, kit, ts, rel, violations }) {
|
|
|
115
120
|
const serviceFile = graph.files.get(serviceRel);
|
|
116
121
|
if (!serviceFile) { report(sourceFile, `${rel} has no ${path.posix.basename(serviceRel)} beside it; a service spec sits next to the service it tests.`); return true; }
|
|
117
122
|
const modules = testingModules(ts, sourceFile);
|
|
123
|
+
if (!modules.length && valueObjectSubjects(ts, serviceFile.sourceFile)) return true;
|
|
118
124
|
if (!modules.length) { report(sourceFile, `${rel} never calls Test.createTestingModule({ providers: [...] }); build the service from a testing module holding exactly its constructor dependencies.`); return true; }
|
|
119
125
|
for (const call of modules) checkTestingModule({ ts, kit, rel, serviceRel, serviceFile, imports, call, report });
|
|
120
126
|
return true;
|