@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
@@ -0,0 +1,153 @@
1
+ import path from 'node:path';
2
+
3
+ export const keyOf = (ts, property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
4
+ const exported = (ts, statement) => statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
5
+
6
+ /** The directly defined functions among the declarations of an exported variable statement; other values are not callable. */
7
+ function callableVariables(ts, statement) {
8
+ const names = [];
9
+ for (const declaration of statement.declarationList.declarations) {
10
+ const initializer = declaration.initializer;
11
+ if (ts.isIdentifier(declaration.name) && initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) names.push(declaration.name.text);
12
+ }
13
+ return names;
14
+ }
15
+
16
+ /** The callable exports of one statement: an overload signature has no body and collapses into its implementation. */
17
+ function callableExports(ts, statement) {
18
+ if (!exported(ts, statement)) return [];
19
+ if (ts.isFunctionDeclaration(statement)) return statement.name && statement.body ? [statement.name.text] : [];
20
+ return ts.isVariableStatement(statement) ? callableVariables(ts, statement) : [];
21
+ }
22
+
23
+ /**
24
+ * The callable subset of a function subject's exports. Constants, enums, objects, types, `export ... from` re-exports and overload
25
+ * signatures do not change which functions the spec must provide; a class or a default-export assignment keeps the class-provider
26
+ * check (null), as does a module with no callable export.
27
+ */
28
+ export function functionSubjectExports(ts, sourceFile) {
29
+ const names = [];
30
+ for (const statement of sourceFile.statements) {
31
+ if (ts.isClassDeclaration(statement) || ts.isExportAssignment(statement)) return null;
32
+ for (const name of callableExports(ts, statement)) if (!names.includes(name)) names.push(name);
33
+ }
34
+ return names.length ? names : null;
35
+ }
36
+
37
+ const decoratorsOf = (ts, node) => (ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : []);
38
+
39
+ /** A value object: an undecorated class whose constructor parameters carry no injection token, so Nest never builds it. */
40
+ function isValueObjectClass(ts, declaration) {
41
+ if (decoratorsOf(ts, declaration).length > 0) return false;
42
+ const constructors = declaration.members.filter(member => ts.isConstructorDeclaration(member));
43
+ return constructors.every(constructor => constructor.parameters.every(parameter => decoratorsOf(ts, parameter).length === 0));
44
+ }
45
+
46
+ /** The names of the value-object classes a module exports, or null when it exports a class that is not one (or none). */
47
+ export function valueObjectSubjects(ts, sourceFile) {
48
+ const classes = sourceFile.statements.filter(statement => ts.isClassDeclaration(statement) && statement.name && exported(ts, statement));
49
+ if (!classes.length || !classes.every(declaration => isValueObjectClass(ts, declaration))) return null;
50
+ return classes.map(declaration => declaration.name.text);
51
+ }
52
+
53
+ /** The value of one literal object property. */
54
+ const propertyValue = (ts, object, key) => {
55
+ const property = object.properties.find(item => keyOf(ts, item) === key);
56
+ return property && ts.isPropertyAssignment(property) ? property.initializer : null;
57
+ };
58
+
59
+ /** A literal value object maps every exported function to its real sibling import. */
60
+ function realFunctions(ts, value, names, imports, rel, serviceRel) {
61
+ if (!value || !ts.isObjectLiteralExpression(value) || value.properties.length !== names.length) return false;
62
+ const remaining = new Set(names);
63
+ for (const property of value.properties) {
64
+ const key = keyOf(ts, property);
65
+ const identifier = ts.isShorthandPropertyAssignment(property) ? property.name : property.initializer;
66
+ if (!remaining.delete(key) || !identifier || !ts.isIdentifier(identifier)) return false;
67
+ const binding = imports.get(identifier.text);
68
+ if (!binding || binding.name !== key || !binding.module.startsWith('.')) return false;
69
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(rel), binding.module)).replace(/\.[cm]?[jt]s$/u, '');
70
+ if (target !== serviceRel.replace(/\.ts$/u, '')) return false;
71
+ }
72
+ return remaining.size === 0;
73
+ }
74
+
75
+ /** Every node satisfying a predicate, within one spec. */
76
+ function matchingNodes(ts, root, predicate) {
77
+ const found = [];
78
+ const visit = node => {
79
+ if (predicate(node)) found.push(node);
80
+ ts.forEachChild(node, visit);
81
+ };
82
+ visit(root);
83
+ return found;
84
+ }
85
+
86
+ /** The declared variable initialized by an expression, including its await and call chain. */
87
+ function initializedVariable(ts, expression) {
88
+ let cursor = expression;
89
+ let compiled = false;
90
+ let awaited = false;
91
+ while (cursor.parent && (ts.isAwaitExpression(cursor.parent) || ts.isPropertyAccessExpression(cursor.parent) || ts.isCallExpression(cursor.parent))) {
92
+ cursor = cursor.parent;
93
+ if (ts.isAwaitExpression(cursor)) awaited = true;
94
+ if (ts.isCallExpression(cursor) && ts.isPropertyAccessExpression(cursor.expression) && cursor.expression.name.text === 'compile') compiled = true;
95
+ }
96
+ if (!compiled || !awaited) return null;
97
+ const declaration = cursor.parent;
98
+ return declaration && ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name) ? declaration.name.text : null;
99
+ }
100
+
101
+ /** The subject variable receiving module.get, whether declared there or assigned in beforeEach. */
102
+ function retrievedVariable(ts, call) {
103
+ let cursor = call;
104
+ while (cursor.parent && (ts.isAwaitExpression(cursor.parent) || ts.isParenthesizedExpression(cursor.parent))) cursor = cursor.parent;
105
+ const parent = cursor.parent;
106
+ if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
107
+ if (parent && ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(parent.left)) return parent.left.text;
108
+ return null;
109
+ }
110
+
111
+ /** A helper returning the retrieved subject is followed to the variable receiving its result. */
112
+ function retrievedSubjects(ts, read, sourceFile) {
113
+ const direct = retrievedVariable(ts, read);
114
+ if (direct) return [direct];
115
+ if (!read.parent || !ts.isReturnStatement(read.parent)) return [];
116
+ let helper = read.parent;
117
+ while (helper.parent && !ts.isFunctionLike(helper)) helper = helper.parent;
118
+ const name = helper.name?.text ?? helper.parent?.name?.text;
119
+ if (!name) return [];
120
+ return matchingNodes(ts, sourceFile, node => ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name)
121
+ .map(node => retrievedVariable(ts, node)).filter(Boolean);
122
+ }
123
+
124
+ /** The same compiled module retrieves the matching token and its result actually calls a subject function. */
125
+ function retrievedSubjectUsed(ts, call, token, names) {
126
+ const moduleName = initializedVariable(ts, call);
127
+ if (!moduleName) return false;
128
+ const sourceFile = call.getSourceFile();
129
+ const reads = matchingNodes(ts, sourceFile, node => {
130
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return false;
131
+ const member = node.expression;
132
+ return ts.isIdentifier(member.expression) && member.expression.text === moduleName && member.name.text === 'get'
133
+ && node.arguments.length === 1 && ts.isIdentifier(node.arguments[0]) && node.arguments[0].text === token.text;
134
+ });
135
+ return reads.some(read => {
136
+ const subjects = retrievedSubjects(ts, read, sourceFile);
137
+ return matchingNodes(ts, sourceFile, node => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
138
+ && ts.isIdentifier(node.expression.expression) && subjects.includes(node.expression.expression.text) && names.includes(node.expression.name.text)).length > 0;
139
+ });
140
+ }
141
+
142
+ /** A pure function service has exactly one real subject value provider, retrieved and exercised through Nest. */
143
+ export function checkFunctionSubject({ ts, rel, serviceRel, imports, call, list, names, report }) {
144
+ const provider = list.elements[0];
145
+ const shape = provider && ts.isObjectLiteralExpression(provider) && provider.properties.length === 2;
146
+ const token = shape ? propertyValue(ts, provider, 'provide') : null;
147
+ const value = shape ? propertyValue(ts, provider, 'useValue') : null;
148
+ if (list.elements.length !== 1 || !token || !ts.isIdentifier(token) || !realFunctions(ts, value, names, imports, rel, serviceRel)) {
149
+ report(provider ?? list, `${rel}: a function-only subject needs exactly one { provide: TOKEN, useValue: { ...realFunctions } } provider containing every callable export imported from ./${path.posix.basename(serviceRel, '.ts')}.`);
150
+ return;
151
+ }
152
+ if (!retrievedSubjectUsed(ts, call, token, names)) report(list, `${rel}: retrieve the function subject with moduleRef.get(TOKEN) from this compiled testing module and exercise a function on the returned subject.`);
153
+ }
@@ -297,11 +297,11 @@ function checkTestTopology(state) {
297
297
  // Test kinds and folders that do not exist: int-spec, harness-spec, src/tests/harness and live/;
298
298
  // a backend spec sits by its kind (beside its subject, or under src/tests/{integration,e2e,contract}/).
299
299
  for (const file of state.tree.files()) {
300
- if (UNSUPPORTED_TEST_SUFFIX.test(file))
300
+ if (state.backend && state.profile === 'be' && UNSUPPORTED_TEST_SUFFIX.test(file))
301
301
  state.finding('BE_TEST_TOPOLOGY', file, `${file} uses an unsupported test kind. Only unit *.spec.ts, *.integration-spec.ts, *.e2e-spec.ts and *.contract-spec.ts exist, each in its own folder under src/tests/.`);
302
- else if (state.backend && UNSUPPORTED_TEST_FOLDER.test(file))
302
+ else if (state.backend && state.profile === 'be' && UNSUPPORTED_TEST_FOLDER.test(file))
303
303
  state.finding('BE_TEST_TOPOLOGY', file, `${file} sits in an unsupported test folder. Unit specs sit beside their subject, flows go under src/tests/e2e/<area>/ and test infrastructure under src/tests/world/.`);
304
- else if (state.backend && file.startsWith('src/tests/') && isExtraTestConfig(file))
304
+ else if (state.backend && state.profile === 'be' && file.startsWith('src/tests/') && isExtraTestConfig(file))
305
305
  state.finding('BE_TEST_TOPOLOGY', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
306
306
  }
307
307
  }
@@ -7,11 +7,13 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
7
7
  /**
8
8
  * R45 `module-per-transport` (BE_MODULE_SHAPE, BE-CONVENTION 1.2 and 1.6). A feature has one Nest module for its
9
9
  * application (`<f>.module.ts` at the feature root) and exactly one module per transport folder
10
- * (`transport/<protocol>/<f>-<protocol>.module.ts`). Never one module per operation, never a module-definition in a feature:
10
+ * (`transport/<protocol>/<f>-<protocol>.module.ts`). Never one module per operation. Every one of these modules has its own
11
+ * module-definition beside it (owner ruling module-definition-everywhere, R295):
11
12
  *
12
- * - a `*.module.ts` file below a transport folder other than `<f>-<protocol>.module.ts`, a `*.module-definition.ts`
13
- * anywhere in a feature, and an `@Module` class (or a `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a
14
- * feature file that is neither the application module nor the transport module of its own folder are refused;
13
+ * - a `*.module.ts` file below a transport folder other than `<f>-<protocol>.module.ts`, a `*.module-definition.ts` that is
14
+ * not the sibling of the application module or of the transport module of its own folder, and an `@Module` class (or a
15
+ * `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a feature file that is neither the application module, the
16
+ * transport module of its own folder nor that module's definition are refused;
15
17
  * - apps import only transport modules, of the kinds their app kind allows: the `composedBy` list of each transport slot
16
18
  * in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root (app.module.ts
17
19
  * or a composition group of its compose/ folder) that lists a feature's application module, or the transport module
@@ -53,13 +55,24 @@ function composedByProtocol(resolver) {
53
55
  return composedBy;
54
56
  }
55
57
 
56
- function reportTransportFileShapes(tree, placeOf, plain) {
58
+ /** The module a feature file may declare: the application module, the transport module of its folder, or a cli folder's module. */
59
+ function ownModuleOf(place, rel, composedModuleOf) {
60
+ if (place.composed) return composedModuleOf(rel);
61
+ return place.protocol === null
62
+ ? `${place.root}/${place.feature}.module.ts`
63
+ : `${place.root}/transport/${place.protocol}/${place.feature}-${place.protocol}.module.ts`;
64
+ }
65
+
66
+ const DEFINITION_FILE = /\.module-definition\.[cm]?tsx?$/u;
67
+ const definitionOf = modulePath => modulePath.replace(/\.module\.([cm]?tsx?)$/u, '.module-definition.$1');
68
+
69
+ function reportTransportFileShapes(tree, placeOf, plain, composedModuleOf) {
57
70
  let transportModules = 0;
58
71
  for (const rel of [...tree.files].sort(byCodeUnit)) {
59
72
  const place = placeOf(rel);
60
73
  if (!place) continue;
61
74
  const base = path.posix.basename(rel);
62
- if (/\.module-definition\.[cm]?tsx?$/u.test(base)) plain(rel, `${rel} is a module-definition inside feature ${place.feature}; a feature module is a static \`@Module\`, and only capabilities under src/modules build their module from a definition. Delete it and list the providers in the module.`, { feature: place.feature });
75
+ if (DEFINITION_FILE.test(base) && rel !== definitionOf(ownModuleOf(place, rel, composedModuleOf))) plain(rel, `${rel} is a module-definition that is not beside a module of feature ${place.feature}; the definition of a module is its sibling ${path.posix.basename(definitionOf(ownModuleOf(place, rel, composedModuleOf)))}, one per application module, transport module or cli folder.`, { feature: place.feature });
63
76
  if (place.protocol && /\.module\.[cm]?tsx?$/u.test(base)) {
64
77
  const expected = `${place.feature}-${place.protocol}.module.ts`;
65
78
  if (place.below.length !== 3 || base !== expected) plain(rel, `${rel} is a second module of the ${place.protocol} transport of ${place.feature}; a transport folder has exactly one module, transport/${place.protocol}/${expected}, never one module per operation. List the operation's providers in that module and delete this file.`, { feature: place.feature, protocol: place.protocol });
@@ -88,13 +101,15 @@ function reportModuleClassLocation(file, statement, place, composedModuleOf, rep
88
101
  if (file.rel !== own) report(file, statement.name, `${statement.name.text} is a Nest module declared in ${file.rel}; a feature declares exactly its application module (${place.feature}.module.ts) and one module per transport folder (${place.feature}-<protocol>.module.ts), never a module per operation.`, { feature: place.feature });
89
102
  }
90
103
 
91
- function reportConfigurableModuleUses(file, checker, kit, ts, place, report) {
104
+ function reportConfigurableModuleUses(file, checker, kit, ts, place, env) {
105
+ const own = ownModuleOf(place, file.rel, env.composedModuleOf);
106
+ const isDefinition = file.rel === definitionOf(own);
92
107
  kit.walk(file.sourceFile, node => {
93
- if (ts.isNewExpression(node) && kit.isImportOf(checker, node.expression, 'ConfigurableModuleBuilder', '@nestjs/common')) {
94
- report(file, node, `ConfigurableModuleBuilder builds a capability's module definition; feature ${place.feature} has static modules only, so declare the providers in its @Module.`, { feature: place.feature });
95
- } else if (ts.isHeritageClause(node)) {
108
+ if (ts.isNewExpression(node) && kit.isImportOf(checker, node.expression, 'ConfigurableModuleBuilder', '@nestjs/common') && !isDefinition) {
109
+ env.report(file, node, `ConfigurableModuleBuilder builds the module definition of one module; in feature ${place.feature} it is written only in ${path.posix.basename(definitionOf(own))}, beside ${path.posix.basename(own)}.`, { feature: place.feature });
110
+ } else if (ts.isHeritageClause(node) && file.rel !== own) {
96
111
  for (const type of node.types) if (kit.isImportOf(checker, type.expression, 'ConfigurableModuleClass', '@nestjs/common')) {
97
- report(file, type, `A feature module never extends ConfigurableModuleClass; feature ${place.feature} has static modules only.`, { feature: place.feature });
112
+ env.report(file, type, `ConfigurableModuleClass is extended by the module of its own definition only (${path.posix.basename(own)}); feature ${place.feature} declares no other configurable module.`, { feature: place.feature });
98
113
  }
99
114
  }
100
115
  return true;
@@ -118,7 +133,7 @@ function collectFeatureModules(graph, kit, ts, placeOf, composedModuleOf, report
118
133
  const checker = kit.checkerOf(file.sourceFile);
119
134
  const place = placeOf(file.rel);
120
135
  moduleClassesInFile(file, checker, place, modules, env);
121
- if (place) reportConfigurableModuleUses(file, checker, kit, ts, place, report);
136
+ if (place) reportConfigurableModuleUses(file, checker, kit, ts, place, env);
122
137
  }
123
138
  return modules;
124
139
  }
@@ -203,7 +218,7 @@ export function checkModulePerTransport(input) {
203
218
  const composedBy = composedByProtocol(resolver);
204
219
 
205
220
  const tree = treeOf(config.root);
206
- const transportModules = reportTransportFileShapes(tree, placeOf, plain);
221
+ const transportModules = reportTransportFileShapes(tree, placeOf, plain, composedModuleOf);
207
222
 
208
223
  const modules = collectFeatureModules(graph, kit, ts, placeOf, composedModuleOf, report);
209
224
 
@@ -0,0 +1,176 @@
1
+ // Infer lexical request identities for SWR hooks without a declared lifecycle contract.
2
+ import { unwrapExpression } from './typescript.mjs';
3
+ import { anyDescendant, normalizedSymbol } from './ast-walks.mjs';
4
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
5
+ import { functionNode, constInitializer, inferredSymbol } from './next-data-symbols.mjs';
6
+
7
+ export function inferredFetcher(ts, checker, expression, seen = new Set()) {
8
+ if (!expression) return null;
9
+ expression = unwrapExpression(ts, expression);
10
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) return expression;
11
+ const symbol = normalizedSymbol(ts, checker, expression);
12
+ if (!symbol || seen.has(symbol)) return null;
13
+ for (const declaration of symbol.getDeclarations?.() ?? []) {
14
+ const fn = functionNode(ts, declaration);
15
+ if (fn) return fn;
16
+ }
17
+ const initializer = constInitializer(ts, symbol);
18
+ return initializer ? inferredFetcher(ts, checker, initializer, new Set(seen).add(symbol)) : null;
19
+ }
20
+
21
+ function importedIdentityReference(ts, checker, expression) {
22
+ const reference = ts.isShorthandPropertyAssignment(expression.parent)
23
+ ? checker.getShorthandAssignmentValueSymbol(expression.parent) : checker.getSymbolAtLocation(expression);
24
+ return (reference?.getDeclarations?.() ?? []).some(declaration => ts.isImportSpecifier(declaration)
25
+ || ts.isImportClause(declaration) || ts.isNamespaceImport(declaration) || ts.isImportEqualsDeclaration(declaration));
26
+ }
27
+
28
+ function identifierIdentityAccess(ts, checker, expression, seen) {
29
+ const imported = importedIdentityReference(ts, checker, expression);
30
+ const symbol = inferredSymbol(ts, checker, expression);
31
+ const initializer = symbol && !seen.has(symbol) ? constInitializer(ts, symbol) : null;
32
+ if (initializer) {
33
+ const alias = identityAccess(ts, checker, initializer, new Set(seen).add(symbol));
34
+ if (alias) return { ...alias, imported: imported || alias.imported };
35
+ }
36
+ return { symbol, parts: [expression.text], imported };
37
+ }
38
+
39
+ function isSelectedMember(ts, expression) {
40
+ return ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)
41
+ && ts.isStringLiteralLike(expression.argumentExpression);
42
+ }
43
+
44
+ function selectedMemberKey(ts, expression) {
45
+ return ts.isPropertyAccessExpression(expression) ? expression.name.text : expression.argumentExpression.text;
46
+ }
47
+
48
+ function identityAccess(ts, checker, expression, seen = new Set()) {
49
+ expression = unwrapExpression(ts, expression);
50
+ if (ts.isIdentifier(expression)) return identifierIdentityAccess(ts, checker, expression, seen);
51
+ if (isSelectedMember(ts, expression)) {
52
+ const base = identityAccess(ts, checker, expression.expression, seen);
53
+ return base ? { ...base, parts: [...base.parts, selectedMemberKey(ts, expression)] } : null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function containerWritten(ts, checker, symbol, source) {
59
+ const rootSymbol = expression => {
60
+ expression = unwrapExpression(ts, expression);
61
+ while (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) expression = unwrapExpression(ts, expression.expression);
62
+ return ts.isIdentifier(expression) ? inferredSymbol(ts, checker, expression) : null;
63
+ };
64
+ return anyDescendant(ts, source, node => {
65
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment
66
+ && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) return rootSymbol(node.left) === symbol;
67
+ if (ts.isDeleteExpression(node)) return rootSymbol(node.expression) === symbol;
68
+ if (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)
69
+ && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)) return rootSymbol(node.operand) === symbol;
70
+ return false;
71
+ });
72
+ }
73
+
74
+ function selectedRequestAlias(ts, checker, expression, seen) {
75
+ const symbol = inferredSymbol(ts, checker, expression);
76
+ const initializer = symbol && !seen.has(symbol) ? constInitializer(ts, symbol) : null;
77
+ if (!initializer) return null;
78
+ const value = unwrapExpression(ts, initializer);
79
+ if (ts.isObjectLiteralExpression(value) && (initializer.getSourceFile() !== expression.getSourceFile()
80
+ || containerWritten(ts, checker, symbol, expression.getSourceFile()))) return null;
81
+ if (ts.isObjectLiteralExpression(value) || ts.isIdentifier(value) || ts.isPropertyAccessExpression(value)
82
+ || ts.isElementAccessExpression(value)) return { symbol, value };
83
+ return null;
84
+ }
85
+
86
+ function selectedRequestProperty(ts, object, key) {
87
+ if (!ts.isObjectLiteralExpression(object) || !object.properties.every(property => ts.isPropertyAssignment(property)
88
+ || ts.isShorthandPropertyAssignment(property))) return null;
89
+ return [...object.properties].reverse().find(candidate => (ts.isIdentifier(candidate.name)
90
+ || ts.isStringLiteralLike(candidate.name)) && candidate.name.text === key) ?? null;
91
+ }
92
+
93
+ function selectedRequestValue(ts, checker, expression, seen = new Set()) {
94
+ expression = unwrapExpression(ts, expression);
95
+ if (ts.isIdentifier(expression)) {
96
+ const alias = selectedRequestAlias(ts, checker, expression, seen);
97
+ return alias ? selectedRequestValue(ts, checker, alias.value, new Set(seen).add(alias.symbol)) : expression;
98
+ }
99
+ if (!isSelectedMember(ts, expression)) return expression;
100
+ const object = selectedRequestValue(ts, checker, expression.expression, seen);
101
+ const property = selectedRequestProperty(ts, object, selectedMemberKey(ts, expression));
102
+ if (!property) return expression;
103
+ const value = ts.isShorthandPropertyAssignment(property) ? property.name : property.initializer;
104
+ return selectedRequestValue(ts, checker, value, seen);
105
+ }
106
+
107
+ function capturedInput(ts, access, region) {
108
+ const declarations = access.symbol?.getDeclarations?.() ?? [];
109
+ return declarations.some(declaration => {
110
+ if (declaration.getSourceFile() === region.getSourceFile() && declaration.pos >= region.pos && declaration.end <= region.end) return false;
111
+ if (declaration.getSourceFile().isDeclarationFile && !access.imported) return false;
112
+ return access.imported || ts.isVariableDeclaration(declaration) || ts.isParameter(declaration) || ts.isBindingElement(declaration);
113
+ });
114
+ }
115
+
116
+ function capturedIdentityAlias(ts, access, seen) {
117
+ // Follow transparent aliases and request objects, so {id} and an alias of id carry the same identity.
118
+ if (access.parts.length !== 1 || seen.has(access.symbol)) return null;
119
+ const initializer = constInitializer(ts, access.symbol);
120
+ if (!initializer) return null;
121
+ const value = unwrapExpression(ts, initializer);
122
+ return ts.isCallExpression(value) || ts.isAwaitExpression(value) || ts.isNewExpression(value) ? null : value;
123
+ }
124
+
125
+ function recordInferredIdentity(scope, access, seen, visit) {
126
+ const { ts, call, region, reasons, identities } = scope;
127
+ if (access?.imported && !(access.symbol?.getDeclarations?.() ?? []).length) {
128
+ reasons.push(`${call.source.fileName}:${sourceLocation(call.source, call.node).line} imported SWR request input ${access.parts.join('.')} cannot be resolved; dataLifecycle is required to declare identities`);
129
+ return true;
130
+ }
131
+ if (!access || !capturedInput(ts, access, region)) return false;
132
+ const alias = capturedIdentityAlias(ts, access, seen);
133
+ if (alias) visit(alias, new Set(seen).add(access.symbol));
134
+ else {
135
+ const binding = access.parts.join('.');
136
+ identities.set(binding, { id: binding, binding, ...access, gatesRequest: false, resource: call.kind === 'mutation' });
137
+ }
138
+ return true;
139
+ }
140
+
141
+ function visitRequestChildren(ts, node, seen, visit) {
142
+ if (ts.isPropertyAssignment(node)) { visit(node.initializer, seen); return; }
143
+ // A method name is not a request input; its receiver and arguments can be.
144
+ if (ts.isCallExpression(node)) {
145
+ const callee = unwrapExpression(ts, node.expression);
146
+ if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) visit(callee.expression, seen);
147
+ for (const argument of node.arguments) visit(argument, seen);
148
+ return;
149
+ }
150
+ if (ts.isVariableDeclaration(node)) {
151
+ if (node.initializer) visit(node.initializer, seen);
152
+ return;
153
+ }
154
+ ts.forEachChild(node, child => { visit(child, seen); });
155
+ }
156
+
157
+ /** Infer lexical request inputs; parameters declared inside the fetcher are supplied by SWR. */
158
+ export function inferredIdentities(context, call, region, reasons) {
159
+ const { ts } = context;
160
+ const identities = new Map();
161
+ const scope = { ts, call, region, reasons, identities };
162
+ const visit = (node, seen = new Set()) => {
163
+ const selectedValue = selectedRequestValue(ts, call.checker, node);
164
+ if (selectedValue !== node && selectedValue !== unwrapExpression(ts, node)) { visit(selectedValue, seen); return; }
165
+ if (recordInferredIdentity(scope, identityAccess(ts, call.checker, node), seen, visit)) return;
166
+ visitRequestChildren(ts, node, seen, visit);
167
+ };
168
+ visit(region.body ?? region);
169
+ return [...identities.values()];
170
+ }
171
+
172
+ export function inferredKeyBindings(context, call, reasons) {
173
+ const key = call.node.arguments[0];
174
+ // A key or its null guard can contain bindings that are not captured request inputs.
175
+ return key ? inferredIdentities(context, call, key, reasons).map(identity => ({ ...identity, id: `key:${identity.id}`, resource: false })) : [];
176
+ }
@@ -0,0 +1,26 @@
1
+ // Symbol reads shared by declared and inferred SWR lifecycle checks.
2
+ import { unwrapExpression } from './typescript.mjs';
3
+ import { normalizedSymbol, normalizedSymbolValue } from './ast-walks.mjs';
4
+ import { isConstVariable } from '../../lib/ts-ast.mjs';
5
+
6
+ export function functionNode(ts, declaration) {
7
+ if (ts.isFunctionDeclaration(declaration) && declaration.body) return declaration;
8
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
9
+ const value = unwrapExpression(ts, declaration.initializer);
10
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) return value;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ export function constInitializer(ts, symbol) {
16
+ const declarations = symbol?.getDeclarations?.() ?? [];
17
+ if (declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]) || !declarations[0].initializer
18
+ || !isConstVariable(ts, declarations[0])) return null;
19
+ return declarations[0].initializer;
20
+ }
21
+
22
+ export function inferredSymbol(ts, checker, node) {
23
+ return ts.isIdentifier(node) && ts.isShorthandPropertyAssignment(node.parent)
24
+ ? normalizedSymbolValue(ts, checker, checker.getShorthandAssignmentValueSymbol(node.parent))
25
+ : normalizedSymbol(ts, checker, node);
26
+ }
@@ -3,9 +3,11 @@ import { canonical, isInside } from './config.mjs';
3
3
  import { parseContract, installedSWR } from './next-data-contract.mjs';
4
4
  import { relativePath, unwrapExpression } from './typescript.mjs';
5
5
  import { anyDescendant, normalizedSymbol, normalizedSymbolValue, valueSymbol as sharedValueSymbol } from './ast-walks.mjs';
6
- import { isConstVariable, sourceLocation } from '../../lib/ts-ast.mjs';
6
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
7
7
  import { byCodeUnit } from '../../lib/list.mjs';
8
8
  import { createNextDataKeyInspector } from './next-data-key.mjs';
9
+ import { functionNode, constInitializer, inferredSymbol } from './next-data-symbols.mjs';
10
+ import { inferredFetcher, inferredIdentities, inferredKeyBindings } from './next-data-inference.mjs';
9
11
  export const SWR_KEY_RULE_ID = 'FE_SWR_KEY_IDENTITY';
10
12
  export const SWR_MUTATION_RULE_ID = 'FE_SWR_MUTATION_RESOURCE_IDENTITY';
11
13
  export const SWR_DATA_RULE_IDS = [SWR_KEY_RULE_ID, SWR_MUTATION_RULE_ID];
@@ -155,15 +157,6 @@ function discoverCalls(config, context, targets) {
155
157
  return calls;
156
158
  }
157
159
 
158
- function functionNode(ts, declaration) {
159
- if (ts.isFunctionDeclaration(declaration) && declaration.body) return declaration;
160
- if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
161
- const value = unwrapExpression(ts, declaration.initializer);
162
- if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) return value;
163
- }
164
- return null;
165
- }
166
-
167
160
  function exportedHook(config, context, entry, reasons) {
168
161
  const source = context.files.find(candidate => canonical(candidate.fileName) === canonical(path.join(config.root, ...entry.path.split('/'))));
169
162
  if (!source) {
@@ -223,19 +216,15 @@ function resolvedIdentities(ts, checker, fn, entry, reasons) {
223
216
  return identities.length === entry.identities.length ? identities : null;
224
217
  }
225
218
 
226
- function constInitializer(ts, symbol) {
227
- const declarations = symbol?.getDeclarations?.() ?? [];
228
- if (declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]) || !declarations[0].initializer
229
- || !isConstVariable(ts, declarations[0])) return null;
230
- return declarations[0].initializer;
231
- }
232
-
233
219
  function violation(config, call, ruleId, message, extra = {}) {
234
220
  return { ruleId, path: relativePath(config.root, call.source.fileName), ...sourceLocation(call.source, call.node), message, ...extra };
235
221
  }
236
222
 
237
- const inspectKey = createNextDataKeyInspector({ anyDescendant, normalizedSymbol, unwrapExpression, constInitializer, sourceLocation, violation,
238
- SWR_KEY_RULE_ID, SWR_MUTATION_RULE_ID });
223
+ const keyInspectorDependencies = { anyDescendant, normalizedSymbol, unwrapExpression, constInitializer, sourceLocation, violation,
224
+ SWR_KEY_RULE_ID, SWR_MUTATION_RULE_ID };
225
+ const inspectKey = createNextDataKeyInspector(keyInspectorDependencies);
226
+
227
+ const inspectInferredKey = createNextDataKeyInspector({ ...keyInspectorDependencies, normalizedSymbol: inferredSymbol });
239
228
 
240
229
  function isUnshadowedCommonJsRequire(ts, checker, expression, seen = new Set(), depth = 0) {
241
230
  expression = unwrapExpression(ts, expression);
@@ -372,7 +361,26 @@ function lifecycleCoverage(ruleIds, contract, installed, calls, details) {
372
361
  swr: { name: installed.name, version: installed.version, major: installed.major } };
373
362
  }
374
363
 
375
- /** Check explicitly declared SWR lifecycle keys against the target program and installed SWR identity. */
364
+ function inspectDefaultLifecycle(config, context, installed, lifecycleCalls, violations, reasons) {
365
+ for (const call of lifecycleCalls) {
366
+ const fetcher = inferredFetcher(context.ts, call.checker, call.node.arguments[1]);
367
+ if (!fetcher) {
368
+ reasons.push(`${relativePath(config.root, call.source.fileName)}:${sourceLocation(call.source, call.node).line} fetcher inputs cannot be inferred; dataLifecycle is required to declare identities`);
369
+ continue;
370
+ }
371
+ const entry = { id: `swr:${relativePath(config.root, call.source.fileName)}:${sourceLocation(call.source, call.node).line}`,
372
+ path: relativePath(config.root, call.source.fileName), export: call.owner?.name?.text ?? '<inferred hook>', kind: call.kind };
373
+ const identities = inferredIdentities(context, call, fetcher, reasons);
374
+ const selected = new Set(identities.map(identity => identity.id));
375
+ const findings = [];
376
+ inspectInferredKey(config, context, entry, call, [...identities, ...inferredKeyBindings(context, call, reasons)], findings, reasons);
377
+ violations.push(...findings.filter(finding => finding.identity === undefined || selected.has(finding.identity)));
378
+ }
379
+ const details = [...new Set(reasons)].sort(byCodeUnit);
380
+ return { violations, coverage: lifecycleCoverage([...SWR_DATA_RULE_IDS], { hooks: lifecycleCalls }, installed, lifecycleCalls.length, details) };
381
+ }
382
+
383
+ /** Check declared lifecycles, or infer captured request identities when production SWR has no declaration. */
376
384
  export function checkFrontendDataLifecycle(config, context) {
377
385
  const ruleIds = [...SWR_DATA_RULE_IDS];
378
386
  const references = swrReferences(config, context);
@@ -381,17 +389,17 @@ export function checkFrontendDataLifecycle(config, context) {
381
389
  return unavailableDataLifecycle(ruleIds, [String(error.message ?? error)]);
382
390
  }
383
391
  if (!contract && !references.found) return { violations: [], coverage: { status: 'not-applicable', ruleIds } };
384
- if (!contract) return { violations: [], coverage: { status: 'unavailable', ruleIds,
385
- details: ['package.json#starci.codePatterns.next.dataLifecycle is required when production source selects SWR', ...references.unsupported] } };
386
392
  const reasons = [];
387
393
  const violations = [];
388
394
  reasons.push(...references.unsupported);
395
+ if (!contract && references.unsupported.length) reasons.push('package.json#starci.codePatterns.next.dataLifecycle is required for unsupported SWR bindings');
389
396
  let installed;
390
397
  try { installed = installedSWR(config.packageRoot ?? config.root); } catch (error) {
391
398
  return { violations, coverage: { status: 'unavailable', ruleIds, details: [String(error.message ?? error)] } };
392
399
  }
393
- if (installed.major !== contract.swr.major) reasons.push(`installed swr ${installed.version} does not match declared major ${contract.swr.major}`);
400
+ if (contract && installed.major !== contract.swr.major) reasons.push(`installed swr ${installed.version} does not match declared major ${contract.swr.major}`);
394
401
  const { lifecycleCalls } = lifecycleCallSets(config, context, installed, reasons);
402
+ if (!contract) return inspectDefaultLifecycle(config, context, installed, lifecycleCalls, violations, reasons);
395
403
  const { hookCache, entriesByHook } = hookEntries(config, context, contract, reasons);
396
404
  inspectHookLifecycle(config, context, lifecycleCalls, hookCache, entriesByHook, violations, reasons);
397
405
  addUndeclaredLifecycleReasons(config, lifecycleCalls, hookCache, reasons);
@@ -96,9 +96,23 @@ function checkBackend(graph) {
96
96
  const reached = reachFrom(graph, rootFiles);
97
97
  const violations = [];
98
98
  reportUncomposedBackendOwners(byUnit, reached, roots, counts, violations);
99
+ reportUncomposedTransports(graph, reached, roots, counts, violations);
99
100
  return { violations, coverage: { status: 'checked', ...counts } };
100
101
  }
101
102
 
103
+ /** A reached feature does not compose its other transport modules. Judge each module's runtime path. */
104
+ function reportUncomposedTransports(graph, reached, roots, counts, violations) {
105
+ for (const [rel, node] of graph.files) {
106
+ if (!node.owner || !rel.startsWith('src/features/') || TEST_FILE.test(rel)) continue;
107
+ if (!/\/transport\/[^/]+\/[^/]+\.module\.[cm]?[jt]sx?$/.test(rel) || reached.has(rel)) continue;
108
+ counts.notComposed += 1;
109
+ violations.push({
110
+ ruleId: 'BE_FEATURE_NOT_COMPOSED', path: rel, owner: node.owner.root, slot: node.slot, appRoots: roots,
111
+ message: `Transport module ${rel} is not composed into any app: no runtime import path leads from ${roots.join(', ')} to it (a type-only import composes nothing).`,
112
+ });
113
+ }
114
+ }
115
+
102
116
  /** Route table of one app: arrays of segments, each {kind:'static'|'dyn'|'catch'|'optcatch', text}. */
103
117
  function routeSegmentsOf(parts) {
104
118
  const segments = [];
@@ -9,13 +9,13 @@ import { byCodeUnit } from '../../lib/list.mjs';
9
9
  * reference's `CodingModule imports DeviceModule` shape). Each capability has one representative module: the only module
10
10
  * of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`, or in a
11
11
  * composition group `apps/<app>/src/compose/<area>.ts` the root spreads (an app root here means the root and its groups).
12
- * It may split itself into sub-modules imported as plain `imports: [Sub]`; a sub-module is never in an app and has one
12
+ * It may split itself into sub-modules imported as `imports: [Sub.register({})]`; a sub-module is never in an app and has one
13
13
  * importer, its parent. Over every `@Module` class of the program:
14
14
  *
15
15
  * - a module in an app root's imports (a `X.register(...)` call or a listed class, helpers of the root included) is
16
16
  * listed once there, is imported by no other module, and, when it is a capability module of src/modules, is registered
17
17
  * with the literal `isGlobal: true` (a bare capability class is not a registration);
18
- * - a module imported by a non-app module has exactly one importer and appears in no app root; the one exception is
18
+ * - a module imported by a non-app module (as `Sub.register({})`) has exactly one importer and appears in no app root; the one exception is
19
19
  * the feature case: a feature's application module is imported by each protocol module of the same feature;
20
20
  * - `isGlobal: true` appears only in an app root.
21
21
  */