@starci/eslint-canon-fe 8.2.13 → 8.2.15

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 (48) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +1 -0
  3. package/client-boundary.mjs +66 -23
  4. package/docs/client-boundary.md +9 -9
  5. package/docs/hooks-folder.md +16 -4
  6. package/docs/hygiene.md +2 -2
  7. package/docs/transport.md +26 -26
  8. package/docs/type-safety.md +6 -6
  9. package/fe-stack.mjs +86 -0
  10. package/formatting.mjs +12 -6
  11. package/hooks-folder.mjs +73 -31
  12. package/hygiene.mjs +4 -4
  13. package/icon.mjs +25 -17
  14. package/index.mjs +2 -0
  15. package/lib/fe-stack.mjs +73 -0
  16. package/lib/scope.mjs +23 -2
  17. package/lib/why.mjs +23 -13
  18. package/next-conventions.mjs +4 -3
  19. package/package.json +1 -1
  20. package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
  21. package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
  22. package/runtime/knowledge/hfs/slots.yaml +319 -179
  23. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  24. package/runtime/knowledge/sonar-gate.yaml +10 -2
  25. package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
  26. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  27. package/runtime/scripts/hfs/architecture/config.mjs +3 -2
  28. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  29. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  30. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  31. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  32. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  33. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  34. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  35. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  36. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  37. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  38. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  39. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  40. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  41. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  42. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  43. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  44. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  45. package/served-locale.mjs +10 -13
  46. package/the-split.mjs +5 -2
  47. package/transport.mjs +81 -67
  48. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
@@ -1,35 +1,27 @@
1
- import path from 'node:path';
2
1
  import { machineKit } from './machine-ast.mjs';
3
- import { isServerActionModule } from './server-action.mjs';
4
- import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, signatureFromSupabase, supabaseClientType, SUPABASE_MODULES, unwrap } from './supabase-ast.mjs';
2
+ import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, supabaseClientType, SUPABASE_MODULES } from './supabase-ast.mjs';
5
3
  import { checkBackendJwt } from './supabase-be.mjs';
6
- import { checkFrontendQueryResults, FE_DB_SLOTS, sameSymbol, symbolOf } from './supabase-results.mjs';
7
4
 
8
5
  /**
9
- * L10-L16, the slot-driven Supabase machine. Supabase is one data transport with one owner on each side:
10
- * `fe.modules.db` / `fe.package.db` in a front end and `be.integrations.supabase` in a back end. The checks below
11
- * recognise provider calls and types by their imported package declarations, not by local variable or type names.
6
+ * L10-L16, the slot-driven Supabase machine. On the back end Supabase is one data transport with one owner,
7
+ * `be.integrations.supabase`. On the front end Supabase is auth only (owner ruling 2026-10-13): `fe.modules.auth` is the one
8
+ * importer of `@supabase/*` and calls only the auth API; data, storage, rpc and realtime go through the back end (modules/api).
9
+ * The checks below recognise provider calls and types by their imported package declarations, not by local variable or type names.
12
10
  */
13
- export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze([
14
- 'FE_SUPABASE_CLIENT_OWNER',
15
- 'FE_DB_RESULT_TYPED',
16
- 'FE_DB_ERROR_HANDLED',
17
- 'FE_SERVICE_ROLE_FORBIDDEN',
18
- 'FE_AUTH_SESSION_TRUST',
19
- 'FE_DB_WRITE_SHAPE',
20
- 'FE_ROUTE_HANDLER_FORBIDDEN',
21
- ]);
11
+ export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze(['FE_SUPABASE_CLIENT_OWNER', 'FE_SERVICE_ROLE_FORBIDDEN']);
22
12
  export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED']);
23
13
 
24
14
  const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
25
15
  const BE_CLIENT_OWNER = 'BE_SUPABASE_CLIENT_OWNER';
26
16
  const FE_SERVICE_ROLE = 'FE_SERVICE_ROLE_FORBIDDEN';
27
- const FE_SESSION_TRUST = 'FE_AUTH_SESSION_TRUST';
28
- const FE_WRITE_SHAPE = 'FE_DB_WRITE_SHAPE';
29
- const FE_ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
30
17
  const BE_SUPABASE_SLOT = 'be.integrations.supabase';
18
+ const FE_AUTH_SLOT = 'fe.modules.auth';
19
+ /** The front-end provider slot: enabled exactly when a connection declares provider supabase. */
20
+ const FE_SUPABASE_PROVIDER_SLOT = 'fe.route.callback';
31
21
  const FE_CONFIG_SLOT = 'fe.modules.config';
32
22
  const SUPABASE_CALLS = new Set(['from', 'rpc']);
23
+ /** The members of a Supabase client that read or write data, files or channels: never called from the front end. */
24
+ const FE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'storage', 'functions', 'realtime', 'channel', 'getChannels', 'removeChannel', 'removeAllChannels']);
33
25
  const supabaseSlotEnabled = (input, slotId) => {
34
26
  const slot = input.graph.resolver.slot(slotId);
35
27
  return Boolean(slot && input.graph.resolver.slotEnabled(slot));
@@ -145,209 +137,43 @@ function checkFrontendSecretNames(input) {
145
137
  return { violations, coverage: { status: 'checked', envReads: state.reads } };
146
138
  }
147
139
 
148
- const isClientModule = (ts, sourceFile) => sourceFile.statements.some((statement, index) => index < 4 && ts.isExpressionStatement(statement)
149
- && ts.isStringLiteralLike(statement.expression) && statement.expression.text === 'use client');
150
-
151
- const isDirectiveStatement = (ts, statement) => ts.isExpressionStatement(statement) && ts.isStringLiteralLike(statement.expression);
152
-
153
- const hasExportModifier = (ts, node) => node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
154
-
155
- const exportedValuesOf = (ts, statement) => {
156
- if (ts.isFunctionDeclaration(statement)) {
157
- return hasExportModifier(ts, statement) && statement.name && statement.body
158
- ? [{ name: statement.name.text, implementation: statement.body, functionNode: statement }] : [];
159
- }
160
- if (!ts.isVariableStatement(statement) || !hasExportModifier(ts, statement)) return [];
161
- return statement.declarationList.declarations
162
- .filter(declaration => ts.isIdentifier(declaration.name) && declaration.initializer)
163
- .map(declaration => {
164
- const functionNode = (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))
165
- && ts.isBlock(declaration.initializer.body) ? declaration.initializer : null;
166
- return { name: declaration.name.text, implementation: declaration.initializer, functionNode };
167
- });
168
- };
169
-
170
- const exportedValues = (ts, sourceFile) => sourceFile.statements.flatMap(statement => exportedValuesOf(ts, statement));
171
-
172
- const exportedActions = (ts, sourceFile) => exportedValues(ts, sourceFile).map(value => value.functionNode).filter(Boolean);
173
- const exportedImplementations = (ts, sourceFile, name) => exportedValues(ts, sourceFile)
174
- .filter(value => value.name === name).map(value => value.implementation);
175
-
176
- const contains = (kit, root, predicate) => {
177
- let found = false;
178
- kit.walk(root, node => {
179
- if (predicate(node)) found = true;
180
- return !found;
181
- });
182
- return found;
183
- };
184
-
185
- const principalCall = (kit, checker, graph, node) => kit.ts.isCallExpression(node) && (() => {
186
- const expression = node.expression;
187
- const name = kit.ts.isPropertyAccessExpression(expression) ? expression.name : expression;
188
- if (!kit.ts.isIdentifier(name) || name.text !== 'getPrincipal') return false;
189
- return kit.declarationsOf(checker, name).some(declaration => {
190
- const rel = kit.graphPath(declaration);
191
- return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
192
- });
193
- })();
194
-
195
- const firstPrincipal = (kit, checker, graph, statement) => {
196
- let call = null;
197
- kit.walk(statement, node => {
198
- if (kit.ts.isAwaitExpression(node) && principalCall(kit, checker, graph, unwrap(kit.ts, node.expression))) call = unwrap(kit.ts, node.expression);
199
- return call === null;
200
- });
201
- if (!call || !kit.ts.isVariableStatement(statement)) return { call, symbol: null };
202
- for (const declaration of statement.declarationList.declarations) {
203
- if (kit.ts.isIdentifier(declaration.name) && declaration.initializer && contains(kit, declaration.initializer, node => node === call)) {
204
- return { call, symbol: symbolOf(kit, checker, declaration.name) };
205
- }
206
- }
207
- return { call, symbol: null };
208
- };
209
-
210
- const typedRefusal = (kit, node) => contains(kit, node, child => (kit.ts.isStringLiteralLike(child) && child.text === 'refused')
211
- || (kit.ts.isIdentifier(child) && /^refused(?:Outcome)?$/iu.test(child.text)));
212
-
213
- const parseUse = (ts, identifier) => {
214
- let current = identifier;
215
- while (current.parent && (ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent))) current = current.parent;
216
- const call = current.parent;
217
- return ts.isCallExpression(call) && call.arguments.includes(current) && ts.isPropertyAccessExpression(call.expression)
218
- && ['parse', 'safeParse'].includes(call.expression.name.text);
219
- };
220
-
221
- const parameterBindings = (ts, name) => {
222
- if (ts.isIdentifier(name)) return [name];
223
- if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) return name.elements.flatMap(element => ts.isBindingElement(element) ? parameterBindings(ts, element.name) : []);
224
- return [];
225
- };
226
-
227
- function inspectServerSessionCalls(kit, file, checker, violations) {
140
+ function inspectFrontendNode(scope, node) {
141
+ const { kit, checker, file, violations, counters } = scope;
228
142
  const { ts } = kit;
229
- if (isClientModule(ts, file.sourceFile)) return;
230
- kit.walk(file.sourceFile, node => {
231
- if (ts.isCallExpression(node) && callName(ts, node) === 'getSession' && signatureFromSupabase(checker, node)) {
232
- reportAt(violations, kit, FE_SESSION_TRUST, file, node, 'auth.getSession() trusts cookie-backed session data in server code; call getPrincipal() (getClaims), and getUser() for sensitive mutations.');
233
- }
234
- return true;
235
- });
236
- }
237
-
238
- function inspectPrincipalProvider(kit, file, checker, violations) {
239
- const { ts } = kit;
240
- if (!FE_DB_SLOTS.has(file.slot)) return;
241
- for (const implementation of exportedImplementations(ts, file.sourceFile, 'getPrincipal')) {
242
- const readsClaims = contains(kit, implementation, node => ts.isCallExpression(node) && callName(ts, node) === 'getClaims'
243
- && signatureFromSupabase(checker, node));
244
- if (!readsClaims) reportAt(violations, kit, FE_SESSION_TRUST, file, implementation,
245
- 'getPrincipal() must establish the server principal through Supabase auth.getClaims().');
143
+ const moduleName = moduleNameOf(ts, node);
144
+ if (moduleName?.startsWith('@supabase/')) {
145
+ counters.imports += 1;
146
+ 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
+ return;
246
148
  }
149
+ if (!ts.isCallExpression(node) || !isSupabaseCall(kit, checker, node)) return;
150
+ counters.calls += 1;
151
+ const member = callName(ts, node);
152
+ 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 });
247
157
  }
248
158
 
249
- function inspectActionDirective(kit, file, action, violations) {
250
- const { ts } = kit;
251
- const first = action.body.statements[0];
252
- const functionLevel = first?.expression?.text === 'use server' && isDirectiveStatement(ts, first);
253
- if (!isServerActionModule(ts, file.sourceFile) && !functionLevel) {
254
- reportAt(violations, kit, FE_WRITE_SHAPE, file, action, 'An exported write function must be a Server Action through a file-level or function-level `use server` directive.');
255
- }
256
- }
257
-
258
- function inspectActionPrincipal(input, kit, file, checker, action, authenticatesAnonymous, violations) {
259
- const { ts } = kit;
260
- // Function directives are the prologue, not work: the principal is the first non-directive statement.
261
- const work = action.body.statements.slice(action.body.statements.findIndex(statement => !isDirectiveStatement(ts, statement)) >>> 0);
262
- const first = work[0];
263
- const principal = first ? firstPrincipal(kit, checker, input.graph, first) : { call: null, symbol: null };
264
- if (!principal.call || !principal.symbol) {
265
- reportAt(violations, kit, FE_SESSION_TRUST, file, first ?? action, 'A write Server Action must begin with `const principal = await getPrincipal()` from the db owner.');
266
- } else if (!authenticatesAnonymous) {
267
- const refuses = work.slice(1).some(statement => ts.isIfStatement(statement)
268
- && contains(kit, statement.expression, node => ts.isIdentifier(node) && sameSymbol(kit, checker, node, principal.symbol))
269
- && typedRefusal(kit, statement.thenStatement));
270
- if (!refuses) reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A write Server Action must refuse an anonymous principal with a typed `refused` Outcome before doing work.');
271
- }
272
- }
273
-
274
- function inspectActionInputs(kit, file, action, checker, violations) {
275
- const { ts } = kit;
276
- for (const parameter of action.parameters.flatMap(item => parameterBindings(ts, item.name))) {
277
- const symbol = symbolOf(kit, checker, parameter);
278
- const references = [];
279
- kit.walk(action.body, node => {
280
- if (ts.isIdentifier(node) && sameSymbol(kit, checker, node, symbol)) references.push(node);
281
- return true;
282
- });
283
- references.sort((a, b) => a.getStart() - b.getStart());
284
- const firstUse = references[0];
285
- if (firstUse && !parseUse(ts, firstUse)) reportAt(violations, kit, FE_WRITE_SHAPE, file, firstUse,
286
- `Server Action input ${parameter.text} is used before schema.parse(...) or schema.safeParse(...).`, { parameter: parameter.text });
287
- }
288
- }
289
-
290
- function inspectSensitiveAction(kit, file, checker, action, violations) {
291
- const { ts } = kit;
292
- const sensitive = /(?:^|\/)(?:billing|payment|payout|refund|charge|subscription|password|credential|role|permission|membership)[/.-]/iu.test(file.rel);
293
- if (sensitive && !contains(kit, action.body, node => ts.isCallExpression(node) && callName(ts, node) === 'getUser' && signatureFromSupabase(checker, node))) {
294
- reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A money, account-security or role-changing Server Action must call auth.getUser() before the mutation.');
295
- }
296
- }
297
-
298
- function inspectWriteAction(input, kit, file, checker, action, authenticatesAnonymous, violations) {
299
- inspectActionDirective(kit, file, action, violations);
300
- inspectActionPrincipal(input, kit, file, checker, action, authenticatesAnonymous, violations);
301
- inspectActionInputs(kit, file, action, checker, violations);
302
- inspectSensitiveAction(kit, file, checker, action, violations);
303
- }
304
-
305
- function inspectWriteModule(input, kit, file, checker, state) {
306
- const { ts } = kit;
307
- if (!FE_DB_SLOTS.has(file.slot) || !path.posix.basename(file.rel).startsWith('write-')) return;
308
- const classified = input.graph.resolver.classifyPath(file.rel);
309
- const belowSlot = classified.root && file.rel.startsWith(`${classified.root}/`) ? file.rel.slice(classified.root.length + 1) : '';
310
- const authenticatesAnonymous = (input.graph.resolver.slot(file.slot)?.anonymousActions ?? []).includes(belowSlot);
311
- const declared = exportedActions(ts, file.sourceFile);
312
- if (!declared.length) reportAt(state.violations, kit, FE_WRITE_SHAPE, file, file.sourceFile, `${file.rel} is a write module but exports no Server Action function.`);
313
- for (const action of declared) {
314
- state.actions += 1;
315
- inspectWriteAction(input, kit, file, checker, action, authenticatesAnonymous, state.violations);
316
- }
317
- }
318
-
319
- function inspectRouteHandler(input, kit, file, state) {
320
- if (!file.rel.endsWith('/route.ts')) return;
321
- state.routes += 1;
322
- const classified = input.graph.resolver.classifyPath(file.rel);
323
- const app = classified.bindings?.app;
324
- const live = app !== undefined && file.rel === `apps/${app}/src/app/health/live/route.ts`;
325
- if (!live && file.slot !== 'fe.route.callback') reportAt(state.violations, kit, FE_ROUTE_HANDLER, file, file.sourceFile,
326
- `${file.rel} is a front-end route handler outside health/live and the fe.route.callback slot; webhooks, cron, public APIs and third-party callbacks belong to be features.`);
327
- }
328
-
329
- function checkFrontendAuthAndRoutes(input) {
159
+ function checkFrontendClientOwner(input) {
330
160
  const kit = machineKit(input);
331
- const state = { violations: [], actions: 0, routes: 0 };
161
+ const violations = [];
162
+ const counters = { imports: 0, calls: 0 };
332
163
  for (const file of input.graph.files.values()) {
333
- const checker = kit.checkerOf(file.sourceFile);
334
- inspectServerSessionCalls(kit, file, checker, state.violations);
335
- inspectPrincipalProvider(kit, file, checker, state.violations);
336
- inspectWriteModule(input, kit, file, checker, state);
337
- inspectRouteHandler(input, kit, file, state);
164
+ const scope = { kit, checker: kit.checkerOf(file.sourceFile), file, violations, counters };
165
+ kit.walk(file.sourceFile, node => inspectFrontendNode(scope, node));
338
166
  }
339
- return { violations: state.violations, coverage: { status: 'checked', actions: state.actions, routes: state.routes } };
167
+ return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [FE_AUTH_SLOT] } };
340
168
  }
341
169
 
342
170
  export function checkFrontendSupabase(input) {
343
- if (!supabaseSlotEnabled(input, 'fe.modules.db')) return { violations: [], coverage: { status: 'not-applicable', reason: 'the Supabase front-end database slot is not enabled' } };
344
- const owner = checkClientOwnership(input, { slots: FE_DB_SLOTS, ruleId: FE_CLIENT_OWNER });
345
- const results = checkFrontendQueryResults(input);
171
+ if (!supabaseSlotEnabled(input, FE_SUPABASE_PROVIDER_SLOT)) return { violations: [], coverage: { status: 'not-applicable', reason: 'no connection declares provider supabase' } };
172
+ const owner = checkFrontendClientOwner(input);
346
173
  const secrets = checkFrontendSecretNames(input);
347
- const auth = checkFrontendAuthAndRoutes(input);
348
174
  return {
349
- violations: [...owner.violations, ...results.violations, ...secrets.violations, ...auth.violations],
350
- coverage: { status: 'checked', owner: owner.coverage, results: results.coverage, secrets: secrets.coverage, auth: auth.coverage },
175
+ violations: [...owner.violations, ...secrets.violations],
176
+ coverage: { status: 'checked', owner: owner.coverage, secrets: secrets.coverage },
351
177
  };
352
178
  }
353
179
 
@@ -1,4 +1,3 @@
1
- import { isServerActionModule } from './server-action.mjs';
2
1
  import { byCodeUnit } from '../../lib/list.mjs';
3
2
 
4
3
  /**
@@ -102,37 +101,8 @@ const triggerOf = (resolver, file) => {
102
101
  return owner ? (resolver.slot(owner.slot)?.trigger ?? null) : null;
103
102
  };
104
103
 
105
- /** The db Server Action an edge targets, or null. */
106
- const serverActionTarget = (graph, edge) => {
107
- if (graph.profile !== 'fe') return null;
108
- const to = graph.resolver.classifyPath(edge.to);
109
- return to.slot === 'fe.modules.db' && /^write-[^/]+\.ts$/u.test(edge.to.split('/').at(-1))
110
- && isServerActionModule(null, graph.files.get(edge.to).sourceFile) ? to : null;
111
- };
112
-
113
- /** A connected block may call a db writer directly when that writer is a file-level Next Server Action. */
114
- const blockCallsServerAction = (graph, edge) => {
115
- if (!serverActionTarget(graph, edge)) return false;
116
- const from = graph.resolver.classifyPath(edge.from);
117
- return from.slot === 'fe.components' && from.kind === 'blocks' && from.role === 'entry';
118
- };
119
-
120
- function reportDirectServerAction(graph, edge, verdict, actionTarget, tierRule, counts, violations) {
121
- if (!(verdict.allowed || verdict.reason === 'notPublicEntry') || !actionTarget
122
- || graph.unit(edge.from) === graph.unit(edge.to) || blockCallsServerAction(graph, edge)) return false;
123
- counts.tierDirection += 1;
124
- violations.push({
125
- ruleId: tierRule,
126
- path: edge.from, line: edge.line, column: edge.column,
127
- specifier: edge.specifier, resolvedPath: edge.to, typeOnly: !edge.runtime,
128
- fromTier: graph.resolver.tierOf(edge.from), toTier: graph.resolver.tierOf(edge.to),
129
- message: `${edge.from} -> ${edge.to}: a db Server Action is called directly only by a connected block entry; hooks and other tiers use their declared data path${edge.runtime ? '' : ' (type-only imports count)'}.`,
130
- });
131
- return true;
132
- }
133
-
134
104
  function reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations) {
135
- if (verdict.allowed || (verdict.reason === 'tierDirection' && blockCallsServerAction(graph, edge)) || !REASON_TEXT[verdict.reason]) return;
105
+ if (verdict.allowed || !REASON_TEXT[verdict.reason]) return;
136
106
  counts[verdict.reason] += 1;
137
107
  const featureToFeature = graph.profile === 'be' && verdict.reason === 'tierDirection' && verdict.fromTier === 'feature' && verdict.toTier === 'feature';
138
108
  const fromKind = featureToFeature ? triggerOf(graph.resolver, edge.from) : null;
@@ -162,8 +132,6 @@ function inspectTierEdge(graph, edge, tierRule, counts, violations, coverage) {
162
132
  return;
163
133
  }
164
134
  coverage.edgesChecked += 1;
165
- const actionTarget = serverActionTarget(graph, edge);
166
- if (reportDirectServerAction(graph, edge, verdict, actionTarget, tierRule, counts, violations)) return;
167
135
  reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
168
136
  }
169
137
 
@@ -2,36 +2,44 @@ import path from 'node:path';
2
2
  import { machineKit } from './machine-ast.mjs';
3
3
 
4
4
  /**
5
- * R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint rules `fetch-only-in-api-client` and
6
- * `client-fetch-has-signal`. ESLint judges one file at a time by the spelling of a call; the machine judges the repository by
7
- * what the checker resolves. A repository has exactly ONE transport client and ONE Outcome union, both named by slot (never
8
- * by a path spelled here): the api package's (`fe.package.api.client`, `fe.package.api.outcome`) when the repository shares
9
- * it, or the only app's (`fe.transport.client`, `fe.transport.outcome`) when the repository declares exactly one app. The
10
- * Outcome homes are the slots the manifest marks `outcomeHome` (the lite edition adds `fe.modules.db.outcome`), not a list kept here.
5
+ * R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint transport rules. ESLint judges one file at a time by
6
+ * the spelling of a call; the machine judges the repository by what the checker resolves. The browser calls the back end through
7
+ * Apollo GraphQL and axios (owner ruling 2026-10-13, both editions). A repository has exactly ONE transport client folder (the auth
8
+ * and no-auth Apollo clients with their link chain) and ONE GraphQLResponse union, both named by slot (never by a path spelled here):
9
+ * the api package's (`fe.package.api.client`, `fe.package.api.outcome`) when several apps call the back end, or the app's own
10
+ * (`fe.transport.client`, `fe.transport.outcome`) when exactly one app does. An app calls the back end when it holds a file of the
11
+ * api owner (modules/api, its client and union) or imports the api package; an app that holds neither (a marketing landing) shares
12
+ * no transport and is not counted. The union homes are the slots the manifest marks `outcomeHome`, not a list kept here.
11
13
  *
12
- * - two or more clients, or two or more Outcome unions, in the repository (two apps each keeping one, or a package one plus
13
- * an app one) are findings on every copy;
14
- * - an app client or app Outcome union in a repository that declares more than one app is a finding (it belongs to the package);
15
- * - the client calls the global `fetch` (a client that never calls it has its transport somewhere else);
16
- * - no other file references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a reference passed as a value
17
- * (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no client at all every such
18
- * reference is a finding that says the repository has no client;
19
- * - no file imports an HTTP library, by static import, `import()` or `require()`;
20
- * - every server reader (`modules/api/<domain>/read-*.ts`) imports the client (or the api package that holds it), so a reader
21
- * is a caller of the one transport and not a second one.
14
+ * - two or more client folders, or two or more unions, in the repository (two apps each keeping one, or a package one plus an
15
+ * app one) are findings on every copy;
16
+ * - an app client or app union in a repository where more than one app calls the back end is a finding (it belongs to the package);
17
+ * - no file outside the client folder references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
18
+ * reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
19
+ * client at all every such reference is a finding that says the repository has no client;
20
+ * - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
21
+ * second transport wherever it is imported, by static import, `import()` or `require()`;
22
+ * - an `ApolloClient` is constructed only inside the client folder;
23
+ * - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`) imports the client (or the api
24
+ * package that holds it), so an operation is a caller of the one transport and not a second one.
22
25
  */
23
26
  export const TRANSPORT_OWNER_RULE_IDS = ['FE_TRANSPORT_OWNER'];
24
27
 
25
28
  const RULE = 'FE_TRANSPORT_OWNER';
26
- /** Packages that send HTTP requests: a second transport when a file imports one. */
29
+ /** Packages that send HTTP requests: a second transport when a file imports one (axios only outside the api owner). */
27
30
  const HTTP_LIBRARIES = new Set(['axios', 'ky', 'ky-universal', 'got', 'node-fetch', 'undici', 'cross-fetch', 'isomorphic-fetch', 'superagent', 'ofetch', 'whatwg-fetch']);
31
+ const REST_LIBRARY = 'axios';
28
32
  const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'self', 'global']);
29
- const API_SLOT = 'fe.modules.api';
30
33
  const APP_CLIENT_SLOT = 'fe.transport.client';
31
34
  const APP_OUTCOME_SLOT = 'fe.transport.outcome';
32
35
  const PACKAGE_API_SLOT = 'fe.package.api';
33
36
  const CLIENT_SLOTS = new Set([APP_CLIENT_SLOT, 'fe.package.api.client']);
34
- const NO_CLIENT = "the repository has no transport client. Create the one client (packages/<family>-api/src/client.ts, or the only app's modules/api/client.ts) and call it.";
37
+ /** The api owner: the slots whose files may import axios (the rest calls and the client's own links). */
38
+ const API_OWNER_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT, ...CLIENT_SLOTS]);
39
+ /** The slots that hold the GraphQL operations of an app or of the api package. */
40
+ const OPERATION_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT]);
41
+ const OPERATION_FILE = /^(?:query|mutation)-[^/]+\.ts$/u;
42
+ const NO_CLIENT = "the repository has no transport client. Create the one Apollo client folder (packages/<family>-api/src/graphql/clients/, or the only app's modules/api/graphql/clients/) and call the back end through it.";
35
43
 
36
44
  /** True when `node` names the global fetch: no import, and no declaration outside a lib declaration file. */
37
45
  function isGlobalObjectFetch(ts, node) {
@@ -59,12 +67,14 @@ function isGlobalFetch(ts, kit, checker, node) {
59
67
  return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
60
68
  }
61
69
 
62
- function reportMultiplicity(items, noun, nextApps, report, list) {
63
- for (const file of items) {
70
+ /** Findings on every copy when there are several, and on an app's own copy in a repository where several apps call the back end. */
71
+ function reportMultiplicity(items, noun, callingApps, report) {
72
+ const list = items.map(item => item.root).join(', ');
73
+ for (const item of items) {
64
74
  if (items.length > 1) {
65
- report(file, file.sourceFile, `The repository has ${items.length} ${noun}s (${list(items)}); it has exactly one: the api package's (packages/<family>-api/src/) when the apps share it, or the only app's. Keep one and delete the others.`);
66
- } else if ((file.slot === APP_CLIENT_SLOT || file.slot === APP_OUTCOME_SLOT) && nextApps.length > 1) {
67
- report(file, file.sourceFile, `${file.rel} is an app's ${noun} in a repository of ${nextApps.length} apps; a repository with several apps keeps its one ${noun} in the api package (packages/<family>-api/src/).`);
75
+ report(item, item.sourceFile, `The repository has ${items.length} ${noun}s (${list}); it has exactly one: the api package's (packages/<family>-api/src/graphql/) when the apps share it, or the only app's. Keep one and delete the others.`);
76
+ } else if ((item.slot === APP_CLIENT_SLOT || item.slot === APP_OUTCOME_SLOT) && callingApps.length > 1) {
77
+ report(item, item.sourceFile, `${item.root} is an app's ${noun} in a repository of ${callingApps.length} apps that call the back end (${callingApps.join(', ')}); a repository where several apps call the back end keeps its one ${noun} in the api package (packages/<family>-api/src/graphql/).`);
68
78
  }
69
79
  }
70
80
  }
@@ -81,40 +91,80 @@ function moduleSpecifierOf(ts, node) {
81
91
  return null;
82
92
  }
83
93
 
84
- function inspectTransportNode({ ts, kit, checker, file, node, isClient, clientCalls, clients, owned, report }) {
94
+ function isApolloClientConstruction(ts, kit, checker, node) {
95
+ if (!ts.isNewExpression(node)) return false;
96
+ const binding = kit.importBinding(checker, node.expression);
97
+ return Boolean(binding && binding.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
98
+ }
99
+
100
+ function inspectLibraryImport({ ts, file, node, transport, report }) {
101
+ const specifier = moduleSpecifierOf(ts, node);
102
+ if (!specifier || !ts.isStringLiteralLike(specifier)) return;
103
+ const library = specifier.text.split('/')[0];
104
+ if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && API_OWNER_SLOTS.has(file.slot))) return;
105
+ const where = library === REST_LIBRARY
106
+ ? 'axios belongs to the api owner only (modules/api/rest, the api package): call its rest functions'
107
+ : `the transport is Apollo GraphQL (${transport}) with axios for the rest calls of modules/api/rest`;
108
+ report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
109
+ }
110
+
111
+ function inspectTransportNode(scope, node) {
112
+ const { ts, kit, checker, file, isClient, owned, report } = scope;
85
113
  const fetchUse = globalFetchUse(ts, kit, checker, node);
86
114
  if (fetchUse !== null) {
87
- if (isClient) clientCalls.value += 1;
88
- else if (fetchUse === 'property') report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
115
+ if (isClient) return false;
116
+ if (fetchUse === 'property') report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
89
117
  else report(file, node, `fetch is used outside the transport client (called, aliased or passed as a value); ${owned}`);
90
118
  return false;
91
119
  }
92
- const specifier = moduleSpecifierOf(ts, node);
93
- if (specifier && ts.isStringLiteralLike(specifier) && HTTP_LIBRARIES.has(specifier.text.split('/')[0])) {
94
- report(file, node, `${specifier.text} is a second HTTP transport; the repository's one transport is built on fetch (${clients.length ? clients.map(item => item.rel).join(', ') : 'no client exists yet'}). Use the client.`, { library: specifier.text });
95
- }
120
+ if (!isClient && isApolloClientConstruction(ts, kit, checker, node)) report(file, node, `${file.rel} constructs an ApolloClient outside the transport client folder; ${owned}`);
121
+ inspectLibraryImport({ ...scope, node });
96
122
  return true;
97
123
  }
98
124
 
99
- function inspectTransportFiles({ files, graph, kit, ts, clientRels, clients, owned, report }) {
125
+ /** True when the operation imports the client folder, or (from an app) the api package that holds it. */
126
+ function reachesClient(graph, file, clientRels) {
127
+ return graph.edges.some(edge => {
128
+ if (edge.from !== file.rel || !edge.runtime) return false;
129
+ if (clientRels.has(edge.to)) return true;
130
+ return file.slot !== PACKAGE_API_SLOT && (graph.files.get(edge.to)?.slot ?? '').startsWith(PACKAGE_API_SLOT);
131
+ });
132
+ }
133
+
134
+ function inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report }) {
100
135
  const appsWithFiles = new Set();
101
- let readers = 0;
136
+ let operations = 0;
102
137
  for (const file of files) {
103
- const bindings = kit.resolver.classifyPath(file.rel).bindings;
104
- if (bindings?.app) appsWithFiles.add(bindings.app);
105
- const checker = kit.checkerOf(file.sourceFile);
106
- const isClient = clientRels.has(file.rel);
107
- const clientCalls = { value: 0 };
108
- kit.walk(file.sourceFile, node => inspectTransportNode({ ts, kit, checker, file, node, isClient, clientCalls, clients, owned, report }));
109
- if (isClient && clientCalls.value === 0) report(file, file.sourceFile, `${file.rel} never calls the global fetch; the client is the one module that owns fetch, with its timeout and abort signal.`);
110
- if (file.slot === API_SLOT && bindings?.app && path.posix.basename(file.rel).startsWith('read-')) {
111
- readers += 1;
112
- const reachesClient = graph.edges.some(edge => edge.from === file.rel && edge.runtime
113
- && (clientRels.has(edge.to) || graph.files.get(edge.to)?.slot === PACKAGE_API_SLOT));
114
- if (!reachesClient) report(file, file.sourceFile, `${file.rel} is a server reader that does not import the transport client; a reader fetches through the repository's one client, never through its own transport.`);
138
+ if (file.bindings?.app) appsWithFiles.add(file.bindings.app);
139
+ const scope = { ts, kit, checker: kit.checkerOf(file.sourceFile), file, isClient: clientRels.has(file.rel), transport, owned, report };
140
+ kit.walk(file.sourceFile, node => inspectTransportNode(scope, node));
141
+ if (OPERATION_SLOTS.has(file.slot) && OPERATION_FILE.test(path.posix.basename(file.rel))) {
142
+ operations += 1;
143
+ if (!reachesClient(graph, file, clientRels)) report(file, file.sourceFile, `${file.rel} is a GraphQL operation that does not import the transport client; an operation runs through the repository's one Apollo client, never through its own transport.`);
115
144
  }
116
145
  }
117
- return { appsWithFiles, readers };
146
+ return { appsWithFiles, operations };
147
+ }
148
+
149
+ /**
150
+ * The declared Next apps that call the back end: those holding a file of an api owner slot (modules/api, the app client, the app
151
+ * union) or importing a file of the api package. An app with neither shares no transport.
152
+ */
153
+ function transportApps(nextApps, files, graph) {
154
+ const byRel = new Map(files.map(file => [file.rel, file]));
155
+ const calling = new Set(files.filter(file => API_OWNER_SLOTS.has(file.slot) && file.slot !== PACKAGE_API_SLOT).map(file => file.bindings?.app));
156
+ for (const edge of graph.edges) {
157
+ const app = byRel.get(edge.from)?.bindings?.app;
158
+ if (app && (byRel.get(edge.to)?.slot ?? '').startsWith(PACKAGE_API_SLOT)) calling.add(app);
159
+ }
160
+ return nextApps.map(item => item.name).filter(name => calling.has(name));
161
+ }
162
+
163
+ /** One instance per client folder (its slot root), carried by the folder's first file. */
164
+ function clientInstances(clients) {
165
+ const byRoot = new Map();
166
+ for (const file of [...clients].sort((a, b) => a.rel.localeCompare(b.rel))) if (!byRoot.has(file.root)) byRoot.set(file.root, file);
167
+ return [...byRoot.values()];
118
168
  }
119
169
 
120
170
  export function checkTransportOwner(input) {
@@ -124,16 +174,22 @@ export function checkTransportOwner(input) {
124
174
  const violations = [];
125
175
  const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
126
176
  const nextApps = resolver.repo.apps.filter(item => item.kind === 'next');
127
- const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e');
177
+ const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e').map(file => {
178
+ const classified = resolver.classifyPath(file.rel);
179
+ return { ...file, root: classified.root ?? file.rel, bindings: classified.bindings };
180
+ });
128
181
  const clients = files.filter(file => CLIENT_SLOTS.has(file.slot));
129
- const outcomes = files.filter(file => resolver.slot(file.slot)?.outcomeHome === true);
182
+ const instances = clientInstances(clients);
183
+ const outcomes = files.filter(file => resolver.slot(file.slot)?.outcomeHome === true).map(file => ({ ...file, root: file.rel }));
130
184
  const clientRels = new Set(clients.map(file => file.rel));
131
- const list = items => items.map(item => item.rel).join(', ');
132
- const owned = clients.length ? `the repository has one transport (${list(clients)}). Call the client and take its Outcome.` : NO_CLIENT;
185
+ const transport = instances.length ? instances.map(item => item.root).join(', ') : 'no client exists yet';
186
+ const owned = instances.length ? `the repository has one transport (${transport}). Call the back end through a modules/api operation and take its GraphQLResponse.` : NO_CLIENT;
133
187
 
134
- // Exactly one client and one Outcome union per repository.
135
- for (const [items, noun] of [[clients, 'transport client'], [outcomes, 'Outcome union']]) reportMultiplicity(items, noun, nextApps, report, list);
188
+ // Exactly one client folder and one GraphQLResponse union per repository.
189
+ const calling = transportApps(nextApps, files, graph);
190
+ reportMultiplicity(instances, 'transport client', calling, report);
191
+ reportMultiplicity(outcomes, 'GraphQLResponse union', calling, report);
136
192
 
137
- const { appsWithFiles, readers } = inspectTransportFiles({ files, graph, kit, ts, clientRels, clients, owned, report });
138
- return { violations, coverage: { status: 'checked', apps: appsWithFiles.size, readers, clients: clients.length, outcomes: outcomes.length } };
193
+ const { appsWithFiles, operations } = inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report });
194
+ return { violations, coverage: { status: 'checked', apps: appsWithFiles.size, operations, clients: instances.length, outcomes: outcomes.length } };
139
195
  }
@@ -156,7 +156,10 @@ function unresolvedReferenceFinding(reference, sourceFile, project, config, work
156
156
  const codeLike = !helpers.assetExtension.test(reference.specifier);
157
157
  const workspaceImport = [...workspaceNames].some(name => helpers.sameOrUnder(reference.specifier, name));
158
158
  const internal = reference.specifier.startsWith('.') || helpers.pathAliasMatches(reference.specifier, project.options.paths) || workspaceImport;
159
- if (codeLike && internal) errors.push({
159
+ // A __generated__/ folder is the gitignored output of the app's codegen step (the GraphQL wire types, fe-stack codegen): it
160
+ // exists after `npm run codegen`, which lint, typecheck and build run first, and the compiler proves those imports there.
161
+ const generated = /(?:^|\/)__generated__(?:\/|$)/.test(reference.specifier);
162
+ if (codeLike && internal && !generated) errors.push({
160
163
  ruleId: 'ARCH_INTERNAL_IMPORT_UNRESOLVED',
161
164
  project: project.relative,
162
165
  path: helpers.relativePath(config.root, sourceFile.fileName),
@@ -1,5 +1,7 @@
1
1
  // edition.mjs - the one edition filter shared by the HFS slot resolver, declaration validation, rule catalog and
2
2
  // edition-specific rule parameters. Full is the default; lite changes data views, never the checks that consume them.
3
+ // Lite is the full architecture deployed without self-hosted infrastructure (owner ruling 2026-10-12): its overlays swap
4
+ // adapters and remove the infra and e2e world, and every slot keeps its own `tests`.
3
5
  import { isPlainObject } from '../../engine/plain-object.mjs';
4
6
  import { EDITIONS } from './manifest-shape.mjs';
5
7
 
@@ -15,7 +17,7 @@ export function slotInEdition(manifest, slot, edition) {
15
17
  return (slot.editions ?? manifest.editions ?? EDITIONS).includes(edition);
16
18
  }
17
19
 
18
- /** The slot as `edition` sees it: lite overlays its data, presence and tracking while forbidding every test world. */
20
+ /** The slot as `edition` sees it: lite overlays its data, presence and tracking; `tests` stays the slot's own (lite keeps the unit specs). */
19
21
  export function effectiveSlot(slot, profile, edition) {
20
22
  if (edition !== 'lite') return slot;
21
23
  const { lite, litePresence, ...base } = slot;
@@ -23,7 +25,7 @@ export function effectiveSlot(slot, profile, edition) {
23
25
  let tracked = slot.tracked;
24
26
  if (presence === 'forbidden') tracked = 'external';
25
27
  else if (tracked === 'external') tracked = 'tracked';
26
- return { ...base, ...lite, presence, tracked, tests: 'none' };
28
+ return { ...base, ...lite, presence, tracked };
27
29
  }
28
30
 
29
31
  /** The template group starci app sync renders `slot` from under `edition`: `liteManagedBy` (else `managedBy`) in lite, `managedBy` otherwise; undefined when unmanaged. */
@@ -59,10 +61,17 @@ export function enforcerJudgedInEdition(rules, kind, id, edition = 'full') {
59
61
  return owners.every((rule) => judgedInEdition(rule, edition) && rule.enforcers.filter((enforcer) => enforcer.kind === kind && enforcer.id === id).every((enforcer) => judgedInEdition(enforcer, edition)));
60
62
  }
61
63
 
62
- /** A frozen copy of one profile's parameters, with its lite overrides applied only in the lite edition. */
64
+ /**
65
+ * A frozen copy of one profile's parameters, with its lite overrides applied only in the lite edition. A lite key whose base
66
+ * value and override are both maps merges one level deep (lite.infraOwners restates only the specifiers whose owner changes);
67
+ * any other value replaces the base.
68
+ */
63
69
  export function editionRuleParams(params, edition) {
64
70
  const { lite, ...base } = params;
65
- const merged = edition === 'lite' && lite ? { ...base, ...lite } : base;
71
+ const merged = edition === 'lite' && lite ? { ...base } : base;
72
+ if (edition === 'lite' && lite) {
73
+ for (const [key, value] of Object.entries(lite)) merged[key] = isPlainObject(value) && isPlainObject(base[key]) ? { ...base[key], ...value } : value;
74
+ }
66
75
  const deepFreeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); } return Object.freeze(value); };
67
76
  return deepFreeze(structuredClone(merged));
68
77
  }