@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/callback-shape.mjs +344 -0
  3. package/client-boundary.mjs +1 -1
  4. package/docs/hooks-folder.md +1 -1
  5. package/env-owner.mjs +120 -9
  6. package/function-budget.mjs +49 -0
  7. package/hooks-folder.mjs +149 -18
  8. package/icon.mjs +67 -0
  9. package/index.mjs +47 -0
  10. package/lib/own-hook-origin.mjs +53 -0
  11. package/lib/program-for.mjs +22 -0
  12. package/lib/pure-owner-helper.mjs +148 -0
  13. package/lib/redux-hook-origin.mjs +44 -0
  14. package/next-conventions.mjs +38 -35
  15. package/package.json +1 -1
  16. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  17. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  18. package/runtime/knowledge/hfs/slots.yaml +344 -62
  19. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  20. package/runtime/knowledge/sonar-gate.yaml +1 -1
  21. package/runtime/modules/models/runtimes.yaml +1 -1
  22. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  23. package/runtime/scripts/api/git/lib.mjs +3 -0
  24. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  25. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  26. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  27. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  28. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  29. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  30. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  31. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  32. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  33. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  34. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  35. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  36. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  37. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  38. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  39. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  40. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  41. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  42. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  43. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  44. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  45. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  46. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  47. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  48. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  49. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  50. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  51. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  52. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  53. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  54. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  55. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  56. package/runtime/scripts/hfs/slots.mjs +1 -0
  57. package/runtime/scripts/lib/env.mjs +20 -0
  58. package/runtime/scripts/lib/event-contract.mjs +107 -0
  59. package/runtime/scripts/lib/json-clone.mjs +61 -0
  60. package/runtime/scripts/lib/list.mjs +4 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  62. package/runtime/scripts/lib/stack-services.mjs +1 -1
  63. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  64. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  65. package/served-locale.mjs +80 -9
  66. package/size-growth.mjs +10 -6
  67. package/swr-family.mjs +197 -0
  68. package/translation.mjs +135 -7
  69. 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
- return checkBackendSourceShapeImpl(config, context);
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 = new Set(['@supabase/ssr', '@supabase/supabase-js']);
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\/(?:ssr|supabase-js|auth-js|postgrest-js|storage-js)(?:\/|$)/u
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) && ts.isPropertyAccessExpression(call.expression) ? call.expression.name.text : null;
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
- names.unshift(current.name.text);
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, kit.ts.isPropertyAccessExpression(node.expression) ? node.expression.expression : node.expression));
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 './backend-app-files.mjs';
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 is one data transport with one owner,
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 SUPABASE_CALLS = new Set(['from', 'rpc']);
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 (!SUPABASE_CALLS.has(method) && !properties.includes('auth') && !properties.includes('storage')) return;
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
- if (!scope.kit.ts.isCallExpression(node)) return;
72
- inspectClientFactoryCall(scope, node);
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) || !isSupabaseCall(kit, checker, node)) return;
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
- let data = null;
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
- enforcer('grammar-entry', FE, ['ARCH_GRAMMAR_CONTRACT_INVALID', 'ARCH_GRAMMAR_EXPORT_BYPASS'], 'Vendor grammar is reached only through its owner entry.'),
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;