@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
@@ -29,8 +29,11 @@ rules:
29
29
  `hfs.json` `sides.fe.apps` with kind `next` (slot `fe.app.next`; the scaffold writes `landing` and `app`) — and all
30
30
  product source lives under fe/apps/<app>/src. An fe/src/ does not exist. Each Next source root
31
31
  (fe/apps/<app>/src) uses app/ for framework adapters; features/{pages,layouts,overlays} for route-facing product
32
- composition; components/{blocks,composites,branches,leaves} for product visuals; hooks/<domain> for React hooks;
33
- and modules/<capability> for cohesive reusable technical and domain capabilities. Every app carries the required
32
+ composition; components/{blocks,composites,branches,leaves} for product visuals and components/providers for the
33
+ client providers the root layout mounts; hooks/ for React hooks (swr, rhf, zustand, socketio, effects, reuseables and
34
+ <domain>); redux/ for the app-wide store; and modules/<capability> for cohesive reusable technical and domain
35
+ capabilities (api, auth, toast, i18n, config, routes, brand, types, storage, superjson, dayjs, utils). Each concern's
36
+ library and home is knowledge/hfs/fe-stack.yaml. Every app carries the required
34
37
  module instances of slot `fe.modules` and one brand.css under modules/brand when it sets brand tokens
35
38
  (`fe.modules.brand`). No other empty role
36
39
  folder is required. Grammar remains a separate product-agnostic package. The only files that may sit directly in the
@@ -78,9 +81,10 @@ rules:
78
81
  when: "A routed authentication scenario"
79
82
  write: "fe/apps/<app>/src/features/pages/AuthenticationPage/index.tsx"
80
83
  - id: case-2
81
- when: "A reusable API client, reader or session capability"
84
+ when: "A back-end call or the session"
82
85
  write: >
83
- fe/apps/<app>/src/modules/api/ or fe/apps/<app>/src/modules/session/ with one explicit public index.ts entry
86
+ slot fe.modules.api (one file per query or mutation) or slot fe.modules.auth, each with one explicit public index.ts
87
+ entry
84
88
  - id: case-3
85
89
  when: "A reusable product visual without scenario ownership"
86
90
  write: "an owner folder of slot `fe.components` under the layer it belongs to — for example fe/apps/<app>/src/components/leaves/<Name>/index.tsx"
@@ -257,7 +261,7 @@ rules:
257
261
  when: "Global app files"
258
262
  write: >
259
263
  app/globals.css, app/global-error.tsx, app/[locale]/error.tsx, app/[locale]/not-found.tsx, app/sitemap.ts,
260
- app/robots.ts, app/health/{live,ready}/route.ts
264
+ app/robots.ts, app/health/live/route.ts (the one route handler, FE-ROUTE)
261
265
  verification:
262
266
  automated:
263
267
  - FE_ERROR_BOUNDARY_MISSING
@@ -287,33 +291,35 @@ rules:
287
291
  relatedExamples: []
288
292
 
289
293
  - id: FE-FOLDER-4
290
- title: Hooks are hooks; readers and transport live in modules
294
+ title: Hooks are hooks; operations and transport live in modules
291
295
  kind: mandatory
292
296
  hfsRules: [R56, R50, R49, R21, R26]
293
297
  requirement: |
294
- hooks/<domain>/ holds React hooks (use*.ts, one hook per file) and exactly one <domain>.shared.ts for shared
295
- non-hook helpers of that domain (key builders, token readers). A file in hooks/ that is not a hook and is not the shared
296
- file is a finding. Server-side readers (fetch and map, no React) live in modules/api/<domain>/read-*.ts and are
297
- wrapped in React cache() when several blocks call them in one request. Generic client plumbing, the Outcome type,
298
- the generated wire types (from be/contracts/, through the root codegen) live in modules/api; environment reading lives in modules/config; every
299
- href builder lives in modules/routes; locale routing lives in modules/i18n; colour values live in modules/brand.
298
+ hooks/ holds React hooks (use*.ts, one hook per file): hooks/swr (one SWR wrapper per modules/api operation, plus
299
+ SwrSideEffects.tsx), hooks/rhf (forms), hooks/zustand/<store> (a store and its hooks), hooks/socketio (sockets.ts,
300
+ SocketIoSideEffects.tsx, enums/, types/), hooks/effects (app-wide side effects), hooks/reuseables (generic hooks) and
301
+ hooks/<domain>/ with exactly one <domain>.shared.ts for shared non-hook helpers of that domain. A file in hooks/ that is
302
+ none of these is a finding. The Apollo clients and their links, one file per query and mutation, the GraphQLResponse
303
+ envelope, the generated wire types (from be/contracts/, through the root codegen) and the axios calls live in
304
+ modules/api; the session lives in modules/auth; mutation feedback in modules/toast; environment reading in
305
+ modules/config; every href builder in modules/routes; locale routing in modules/i18n; colour values in modules/brand.
300
306
  A helper name defined twice across hook files is a finding.
301
307
  rationale: |
302
308
  A rule that says what a hooks folder is also says where everything else goes, so nobody copies a helper sixteen
303
- times or fills hooks/ with server fetch functions.
309
+ times or fills hooks/ with transport calls.
304
310
  cases:
305
311
  - id: case-1
306
- when: "Authentication request lifecycle"
307
- write: "fe/apps/<app>/src/hooks/authentication/useAuthentication.ts"
312
+ when: "A query the sales page reads"
313
+ write: "fe/apps/<app>/src/hooks/swr/api/graphql/queries/useQuerySalesSummarySwr.ts over modules/api/graphql/queries/query-sales-summary.ts"
308
314
  - id: case-2
309
315
  when: "A helper shared by the hooks of one domain"
310
316
  write: "fe/apps/<app>/src/hooks/sales/sales.shared.ts, for example useSalesAccessToken's token reader"
311
317
  - id: case-3
312
- when: "A server read used by several blocks"
313
- write: "fe/apps/<app>/src/modules/api/sales/read-sales-summary.ts, exported through modules/api/index.ts"
318
+ when: "A form"
319
+ write: "fe/apps/<app>/src/hooks/rhf/useSendHandoffForm.ts"
314
320
  - id: case-4
315
321
  when: "The API client"
316
- write: "fe/apps/<app>/src/modules/api/client.ts and fe/apps/<app>/src/modules/api/outcome.ts"
322
+ write: "fe/apps/<app>/src/modules/api/graphql/clients/ (auth and no-auth Apollo clients, links/) and fe/apps/<app>/src/modules/api/graphql/types.ts"
317
323
  - id: case-5
318
324
  when: "Intrinsic custom helper"
319
325
  write: "fe/apps/<app>/src/hooks/ui/useAutoScroll.ts; direct useRef and useState calls may remain in their visual owner"
@@ -81,13 +81,17 @@ overall:
81
81
  cognitiveComplexity:
82
82
  rule: typescript:S3776
83
83
  via: overall.issues
84
- # Lite is the same quality policy without a test or coverage world. Readers select this block from hfs.json edition;
85
- # keeping both gates in this document makes every threshold data, never a branch-local restatement.
84
+ # Lite is the same quality policy, coverage included: a lite back end keeps full's unit run and its 100 per file on the measured
85
+ # logic files (owner ruling 2026-10-12, R212 case-1). Readers select this block from hfs.json edition; the gate keeps its own
86
+ # name so a lite project's server gate stays a separate object, and every threshold below equals full's.
86
87
  lite:
87
88
  gate:
88
89
  name: starci-quality-lite
89
90
  newCodePeriod: {type: NUMBER_OF_DAYS, value: 30}
90
91
  newCode:
92
+ coverage:
93
+ metric: new_coverage
94
+ minPercent: 100
91
95
  ignoreBelowChangedLines: 20
92
96
  duplication:
93
97
  metric: new_duplicated_lines_density
@@ -109,6 +113,10 @@ lite:
109
113
  hotspots:
110
114
  metric: security_hotspots_reviewed
111
115
  minReviewedPercent: 100
116
+ coverage:
117
+ metric: coverage
118
+ minPercent: 100
119
+ perFile: true
112
120
  duplication:
113
121
  metric: duplicated_lines_density
114
122
  maxPercent: 3
@@ -266,3 +266,51 @@ removed:
266
266
  use: 'probeDraft reads only and returns verdict, draft and sends; a foreign draft receives no keys'
267
267
  since: 1.0.0-alpha.9
268
268
  match: '\b(?:draftProbe|probeDraft)\.(?:restore|restored|removed|after)\b'
269
+ - id: code-fe-db-result-typed
270
+ kind: code
271
+ name: FE_DB_RESULT_TYPED
272
+ use: 'the front end reads no data through Supabase (R217, auth only in fe.modules.auth); reads are GraphQL queries of modules/api wrapped by hooks/swr, typed by graphql-codegen (R52 FE_WIRE_GENERATED)'
273
+ since: 1.0.0-alpha.13
274
+ - id: code-fe-db-error-handled
275
+ kind: code
276
+ name: FE_DB_ERROR_HANDLED
277
+ use: 'a failure arrives as the GraphQLResponse envelope of modules/api/graphql/types.ts and is never collapsed into empty data (R51 FE_HTTP_STATUS_COLLAPSE)'
278
+ since: 1.0.0-alpha.13
279
+ - id: code-fe-auth-session-trust
280
+ kind: code
281
+ name: FE_AUTH_SESSION_TRUST
282
+ use: 'the front end runs no server code that trusts a session (R243 FE_SERVER_ACTION_FORBIDDEN); the back end verifies the access token against the JWKS (R219 BE_SUPABASE_JWT_VERIFIED)'
283
+ since: 1.0.0-alpha.13
284
+ - id: code-fe-db-write-shape
285
+ kind: code
286
+ name: FE_DB_WRITE_SHAPE
287
+ use: 'a write is a GraphQL mutation of modules/api wrapped by a useMutate<X>Swr hook and reported by modules/toast; Server Actions are refused (R243 FE_SERVER_ACTION_FORBIDDEN)'
288
+ since: 1.0.0-alpha.13
289
+ - id: slot-fe-modules-db
290
+ kind: vocabulary
291
+ name: fe.modules.db
292
+ use: 'the front end reads and writes through the back end GraphQL API in fe.modules.api, wrapped by hooks/swr; Supabase on the front end is fe.modules.auth only (owner ruling 2026-10-13)'
293
+ since: 1.0.0-alpha.13
294
+ match: '\bfe\.modules\.db\b'
295
+ - id: slot-fe-package-db
296
+ kind: vocabulary
297
+ name: fe.package.db
298
+ use: 'a shared front-end data client is fe.package.api (Apollo clients and the GraphQLResponse union); Supabase on the front end is fe.modules.auth only'
299
+ since: 1.0.0-alpha.13
300
+ match: '\bfe\.package\.db\b'
301
+ - id: slot-be-feature-realtime-supabase
302
+ kind: vocabulary
303
+ name: be.feature.realtime.supabase
304
+ use: 'both editions push through the socket.io gateway be.feature.realtime.websocket; lite runs it in the api process'
305
+ since: 1.0.0-alpha.13
306
+ match: '\bbe\.feature\.realtime\.supabase\b'
307
+ - id: slot-key-anonymous-actions
308
+ kind: vocabulary
309
+ name: anonymousActions
310
+ use: 'the front end holds no Server Action (R243 FE_SERVER_ACTION_FORBIDDEN), so no slot lists anonymous ones'
311
+ since: 1.0.0-alpha.13
312
+ - id: lint-heroicons-is-the-glyph-vendor
313
+ kind: vocabulary
314
+ name: heroicons-is-the-glyph-vendor
315
+ use: 'the eslint-fe rule is one-icon-set: the application icon set is @phosphor-icons/react (knowledge/hfs/fe-stack.yaml icons), imported only by the Icon leaf; the grammar Icon takes whatever glyph the app supplies'
316
+ since: 1.0.0-alpha.13
@@ -1,6 +1,5 @@
1
1
  import { builtinModules } from 'node:module';
2
2
  import { sourceLocation } from '../../lib/ts-ast.mjs';
3
- import { isServerActionModule } from './server-action.mjs';
4
3
 
5
4
  /**
6
5
  * R55 `client-reaches-server` (FE_CLIENT_REACHES_SERVER), the repository half of the eslint rule `client-no-server-import`,
@@ -58,7 +57,6 @@ const scanClientEntry = (entry, { ts, graph, runtime, violations }) => {
58
57
  const seen = new Map([[entry.rel, { chain: [entry.rel], first: null }]]);
59
58
  const queue = [entry.rel];
60
59
  for (const rel of queue) {
61
- if (isServerActionModule(ts, graph.files.get(rel).sourceFile)) continue;
62
60
  const state = seen.get(rel);
63
61
  for (const hit of runtime.serverOf(rel)) {
64
62
  const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
@@ -199,7 +199,8 @@ function inferredLayout(root, workspaces) {
199
199
  features: collect('src/features'),
200
200
  components: [...new Set(components)],
201
201
  hooks: collect('src/hooks'),
202
- modules: collect('src/modules'),
202
+ // src/redux/ (fe.redux) is a modules-tier owner beside src/modules/ (owner ruling 2026-10-13, fe-stack state-global).
203
+ modules: [...collect('src/modules'), ...collect('src/redux')],
203
204
  transport: collect('src/modules/api'),
204
205
  };
205
206
  }
@@ -291,7 +292,7 @@ function derivedGrammar(root, packageRoot, workspaces, apps = []) {
291
292
  if (declaresGrammar(pkg)) consumerManifests.push(manifest);
292
293
  }
293
294
  if (!consumerManifests.length) return null;
294
- return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react'] };
295
+ return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react', '@xyflow/react'] };
295
296
  }
296
297
 
297
298
  /**
@@ -8,7 +8,7 @@
8
8
  * so a file only a spec imports is dead, on purpose. Type-only imports reach a file.
9
9
  *
10
10
  * Owner = every graph.ownerRoots unit whose tier is not `app` and whose public entry file is in the graph (index.ts or
11
- * index.tsx, a package's src/index.ts). The `entries` its slot declares (slot data, e.g. fe.modules.db browser.ts) are further
11
+ * index.tsx, a package's src/index.ts). The `entries` its slot declares (slot data: a second public entry file of the owner) are further
12
12
  * public entries of the same owner, judged exactly like the entry. The exported names of an entry are its named exports, `export { a as b }`,
13
13
  * `export { x } from`, declarations with `export`, and `default` for `export default`. `export *` is refused by owners.mjs
14
14
  * and contributes no name here.
@@ -34,6 +34,7 @@ import fs from 'node:fs';
34
34
  import { canonical } from './config.mjs';
35
35
  import path from 'node:path';
36
36
  import { treeOf } from './required-files.mjs';
37
+ import { globExpression } from '../../lib/glob.mjs';
37
38
 
38
39
  export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
39
40
 
@@ -59,7 +60,10 @@ export function entryOf(graph, config, key, owner) {
59
60
  /** The additional public entry files (repository-relative, in the graph) the slot manifest declares for the owner (slot field `entries`, on the slot that holds its entry file). */
60
61
  function extraEntries(graph, entry) {
61
62
  const found = graph.resolver.classifyPath(entry);
62
- return (found.slot ? graph.resolver.slot(found.slot)?.entries ?? [] : []).map(name => `${found.root}/${name}`).filter(rel => graph.files.has(rel));
63
+ const names = found.slot ? graph.resolver.slot(found.slot)?.entries ?? [] : [];
64
+ // an entry holding `*` (one file per operation) names every graph file it matches
65
+ const matches = name => [...graph.files.keys()].filter(rel => globExpression(`${found.root}/${name}`).test(rel));
66
+ return names.flatMap(name => (name.includes('*') ? matches(name) : [`${found.root}/${name}`].filter(rel => graph.files.has(rel))));
63
67
  }
64
68
 
65
69
  const importBindings = (ts, declaration) => {
@@ -33,13 +33,22 @@ const isGraphqlRegistration = (kit, checker, node) => kit.ts.isCallExpression(no
33
33
  && ['forRoot', 'forRootAsync'].includes(node.expression.name.text)
34
34
  && kit.isImportOf(checker, node.expression.expression, 'GraphQLModule', '@nestjs/graphql');
35
35
 
36
+ /**
37
+ * The formatter a `formatError` value names: the value itself, or the callee of an arrow whose body only calls it (an arrow that
38
+ * binds the errors service, `(formatted, error) => formatGraphqlError(errors, formatted, error)`).
39
+ */
40
+ function formatterOf(ts, value) {
41
+ if (value && ts.isArrowFunction(value) && ts.isCallExpression(value.body)) return value.body.expression;
42
+ return value;
43
+ }
44
+
36
45
  function passesMaskedFormatError(kit, inErrors, checker, call) {
37
46
  const { ts } = kit;
38
47
  let masked = false;
39
48
  for (const argument of call.arguments) {
40
49
  kit.walk(argument, inner => {
41
50
  if ((ts.isPropertyAssignment(inner) || ts.isShorthandPropertyAssignment(inner)) && kit.propertyNameText(inner.name) === 'formatError'
42
- && inErrors(checker, kit.valueOfProperty(inner))) masked = true;
51
+ && inErrors(checker, formatterOf(ts, kit.valueOfProperty(inner)))) masked = true;
43
52
  });
44
53
  }
45
54
  return masked;
@@ -8,7 +8,7 @@ import { checkWorldRenderBoundaries } from './frontend-world.mjs';
8
8
  import { checkGrammar } from './frontend-grammar.mjs';
9
9
  import { checkRoute } from './frontend-routing.mjs';
10
10
  const FEATURE_TIERS = new Set(['pages', 'layouts', 'overlays']);
11
- const COMPONENT_TIERS = new Set(['blocks', 'composites', 'branches', 'leaves']);
11
+ const COMPONENT_TIERS = new Set(['blocks', 'composites', 'branches', 'leaves', 'providers']);
12
12
 
13
13
  function absolute(root, relative) {
14
14
  return path.resolve(root, ...relative.split('/'));
@@ -90,7 +90,7 @@ function checkFrontendSourceLayout(config, sourceFile, roots) {
90
90
  if (located.role === 'hooks') valid = located.relative.includes('/') || roleEntry(located.relative);
91
91
  return valid ? [] : [{ ruleId: 'FE_SOURCE_LAYOUT_INVALID', path: relativePath(config.root, sourceFile.fileName), line: 1, column: 1,
92
92
  message: ({ features: 'Frontend features contain only pages, layouts and overlays plus an optional root public entry.',
93
- components: 'Frontend components contain only blocks, composites, branches and leaves plus an optional root public entry.',
93
+ components: 'Frontend components contain only blocks, composites, branches, leaves and providers plus an optional root public entry.',
94
94
  hooks: 'Every authored custom hook is grouped below a domain folder under hooks; only the root public entry may sit directly under hooks.' })[located.role] }];
95
95
  }
96
96
 
@@ -139,8 +139,15 @@ function reportExportedHooks(state) {
139
139
  }
140
140
  }
141
141
 
142
+ /** True when the slot that owns this file names it as its `hooks` role (redux/hooks.ts, modules/toast/hooks.ts): the owner's own typed hooks. */
143
+ function slotHooksRole(config, fileName) {
144
+ const found = config.hfs?.classifyPath?.(relativePath(config.root, fileName));
145
+ const role = found?.slot ? config.hfs.slot(found.slot)?.roles?.hooks : undefined;
146
+ return typeof role === 'string' && path.basename(fileName) === role;
147
+ }
148
+
142
149
  function checkCustomHookLocations(config, context, sourceFile, roots) {
143
- if (insideAny(roots.hooks, sourceFile.fileName)) return [];
150
+ if (insideAny(roots.hooks, sourceFile.fileName) || slotHooksRole(config, sourceFile.fileName)) return [];
144
151
  const { ts } = context;
145
152
  const state = { ts, config, sourceFile, checker: context.checkerFor(sourceFile.fileName), violations: [], seen: new Set() };
146
153
  visitHookDeclarations(state, sourceFile);
@@ -43,7 +43,8 @@ export const HFS_RULE_IDS = [
43
43
 
44
44
  const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
45
45
  const HUSKY_HOOKS_PATH = /^\.husky(?:\/_)?\/?$/u;
46
- const BACKEND_SRC_CHILDREN = new Set(['features', 'modules', 'tests']);
46
+ /** The folders of a back end's src/ the slot manifest knows (features, modules, tests, assets): read from the be slot paths, never listed here. */
47
+ const backendSrcChildrenOf = resolver => new Set(resolver.slots().filter(slot => slot.profiles.includes('be')).flatMap(slot => braceVariants(slot.path)).map(variant => /^src\/([a-z][a-z-]*)\//.exec(variant)?.[1]).filter(Boolean));
47
48
  const moduleTiersOf = resolver => new Set(resolver.slots().filter(slot => slot.profiles.includes('be')).flatMap(slot => braceVariants(slot.path)).map(variant => /^src\/modules\/([a-z][a-z-]*)\//.exec(variant)?.[1]).filter(Boolean)); // the folders of src/modules/ the slot manifest knows (domain, platform, integrations, events, queues, projections)
48
49
  // Owner test layout 2026-09-30: unit `<name>.spec.ts`, integration, e2e and contract by folder and suffix; int-spec and harness-spec stay banned.
49
50
  const UNSUPPORTED_TEST_SUFFIX = /\.(?:int|harness)-spec\.[cm]?[jt]sx?$/u;
@@ -274,9 +275,11 @@ function checkApplications(state) {
274
275
  function checkBackendSourceLayout(state) {
275
276
  if (!state.backend) return;
276
277
  const { tree, resolver, finding } = state;
278
+ const srcChildren = backendSrcChildrenOf(resolver);
279
+ const srcList = [...srcChildren].sort(byCodeUnit).map(name => name + '/').join(', ');
277
280
  for (const child of tree.children('src').sort(byCodeUnit)) {
278
- if (!BACKEND_SRC_CHILDREN.has(child))
279
- finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
281
+ if (!srcChildren.has(child))
282
+ finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only ${srcList}; ${child} must move to its owner.`);
280
283
  }
281
284
  for (const tier of tree.children('src/modules').sort(byCodeUnit)) {
282
285
  if (!moduleTiersOf(resolver).has(tier))
@@ -48,6 +48,7 @@ import { checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS } from './fe-slot-allows.mjs
48
48
  import { checkI18nKeys, I18N_KEYS_RULE_IDS } from './i18n-keys.mjs';
49
49
  import { checkDocLanguage, DOC_LANGUAGE_RULE_IDS } from './doc-language.mjs';
50
50
  import { checkBackendSupabase, checkFrontendSupabase, BACKEND_SUPABASE_RULE_IDS, FRONTEND_SUPABASE_RULE_IDS } from './supabase.mjs';
51
+ import { checkServerActions, SERVER_ACTION_RULE_IDS } from './server-action.mjs';
51
52
  import { LINT_CODES, onLintSurface } from './surface.mjs';
52
53
  import { byCodeUnit } from '../../lib/list.mjs';
53
54
 
@@ -98,6 +99,7 @@ const FRONTEND_MACHINE = {
98
99
  feSlotAllows: [checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS],
99
100
  i18nKeys: [checkI18nKeys, I18N_KEYS_RULE_IDS],
100
101
  supabaseFrontend: [checkFrontendSupabase, FRONTEND_SUPABASE_RULE_IDS],
102
+ serverActions: [checkServerActions, SERVER_ACTION_RULE_IDS],
101
103
  };
102
104
 
103
105
  /** Errors the machine reports when a program cannot be built or an edge cannot be proven: it cannot judge, so it refuses. */
@@ -2,17 +2,23 @@ import path from 'node:path';
2
2
  import { canonical, isInside } from './config.mjs';
3
3
  import { relativePath, workspaceExportSources } from './typescript.mjs';
4
4
  import { sourceLocation } from '../../lib/ts-ast.mjs';
5
- import { isServerActionModule } from './server-action.mjs';
5
+ import { globExpression } from '../../lib/glob.mjs';
6
+ import { posixPath } from '../../lib/path-key.mjs';
6
7
 
7
8
  function absolute(root, relative) {
8
9
  return canonical(path.resolve(root, ...relative.split('/')));
9
10
  }
10
11
 
11
- /** The extra public entries the slot manifest declares for the owner (slot field `entries`, on the slot that holds its entry file), as absolute paths. */
12
+ /**
13
+ * The extra public entries the slot manifest declares for the owner (slot field `entries`, on the slot that holds its entry file):
14
+ * exact files as absolute paths, and the entries holding a `*` (one file per operation, `graphql/queries/query-*.ts`) as patterns.
15
+ */
12
16
  function slotEntries(config, owner) {
13
17
  const found = config.hfs?.classifyPath?.(owner.entry);
14
18
  const names = found?.slot ? config.hfs.slot(found.slot)?.entries ?? [] : [];
15
- return names.map(name => absolute(config.root, `${found.root}/${name}`));
19
+ const exact = names.filter(name => !name.includes('*')).map(name => absolute(config.root, `${found.root}/${name}`));
20
+ const patterns = names.filter(name => name.includes('*')).map(name => globExpression(`${posixPath(absolute(config.root, found.root))}/${name}`));
21
+ return { exact, patterns };
16
22
  }
17
23
 
18
24
  /** The owner's public entries: its declared entry, the `entries` its slot declares, plus every source file its package.json `exports` maps when the owner is a workspace package. */
@@ -21,9 +27,10 @@ function ownerDeclarations(config, context) {
21
27
  const root = absolute(config.root, owner.root);
22
28
  const entry = absolute(config.root, owner.entry);
23
29
  const workspace = context.workspaces?.find(item => item.root === root);
24
- const declared = slotEntries(config, owner);
30
+ const { exact: declared, patterns } = slotEntries(config, owner);
25
31
  const entries = new Set([entry, ...declared, ...(workspace ? workspaceExportSources(context.ts, workspace) : [])]);
26
- return { ...owner, root, entry, entries, declared };
32
+ const isEntry = file => entries.has(file) || patterns.some(pattern => pattern.test(posixPath(file)));
33
+ return { ...owner, root, entry, entries, isEntry, declared };
27
34
  }).sort((a, b) => b.root.length - a.root.length);
28
35
  }
29
36
 
@@ -35,7 +42,7 @@ function reexportSteps(context, current) {
35
42
  return (context.edges.get(current.file) ?? []).filter(candidate => candidate.reexport).map(candidate => ({ file: candidate.to, chain: [...current.chain, candidate.to] }));
36
43
  }
37
44
 
38
- function privateOwnerChain(context, owners, actionEntries, edge) {
45
+ function privateOwnerChain(context, owners, edge) {
39
46
  const sourceOwner = ownerOf(owners, edge.from);
40
47
  const queue = [{ file: edge.to, chain: [edge.from, edge.to] }];
41
48
  const visited = new Set();
@@ -44,7 +51,7 @@ function privateOwnerChain(context, owners, actionEntries, edge) {
44
51
  if (visited.has(current.file)) continue;
45
52
  visited.add(current.file);
46
53
  const owner = ownerOf(owners, current.file);
47
- if (owner && sourceOwner?.id !== owner.id) return owner.entries.has(current.file) || actionEntries.has(current.file) ? null : { owner, chain: current.chain };
54
+ if (owner && sourceOwner?.id !== owner.id) return owner.isEntry(current.file) ? null : { owner, chain: current.chain };
48
55
  queue.push(...reexportSteps(context, current));
49
56
  }
50
57
  return null;
@@ -61,7 +68,7 @@ function arrangesSchema(config, from, to) {
61
68
  && resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
62
69
  }
63
70
 
64
- function publicEntryViolations(config, context, owners, sourceFiles, actionEntries) {
71
+ function publicEntryViolations(config, context, owners, sourceFiles) {
65
72
  const violations = [];
66
73
  for (const owner of owners) {
67
74
  const sourceFile = sourceFiles.get(owner.entry);
@@ -73,7 +80,6 @@ function publicEntryViolations(config, context, owners, sourceFiles, actionEntri
73
80
  const publicFiles = new Set([
74
81
  sourceFile,
75
82
  ...owner.declared.map(file => sourceFiles.get(file)).filter(Boolean),
76
- ...[...actionEntries].filter(file => isInside(owner.root, file)).map(file => sourceFiles.get(file)).filter(Boolean),
77
83
  ]);
78
84
  for (const entryFile of publicFiles) {
79
85
  for (const statement of entryFile.statements) if (context.ts.isExportDeclaration(statement) && !statement.exportClause) {
@@ -90,12 +96,12 @@ function publicEntryViolations(config, context, owners, sourceFiles, actionEntri
90
96
  return violations;
91
97
  }
92
98
 
93
- function ownerImportViolations(config, context, owners, actionEntries) {
99
+ function ownerImportViolations(config, context, owners) {
94
100
  const violations = [];
95
101
  for (const sourceFile of context.files) {
96
102
  const from = canonical(sourceFile.fileName);
97
103
  for (const edge of context.edges.get(from) ?? []) {
98
- const bypass = privateOwnerChain(context, owners, actionEntries, edge);
104
+ const bypass = privateOwnerChain(context, owners, edge);
99
105
  if (!bypass) continue;
100
106
  if (arrangesSchema(config, from, bypass.chain.at(-1))) continue;
101
107
  violations.push({
@@ -119,11 +125,8 @@ export function checkOwners(config, context) {
119
125
  if (!config.owners?.length) return [];
120
126
  const owners = ownerDeclarations(config, context);
121
127
  const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
122
- const actionEntries = new Set(config.kinds.includes('frontend')
123
- ? [...sourceFiles].filter(([, file]) => isServerActionModule(context.ts, file)).map(([name]) => name)
124
- : []);
125
128
  return [
126
- ...publicEntryViolations(config, context, owners, sourceFiles, actionEntries),
127
- ...ownerImportViolations(config, context, owners, actionEntries),
129
+ ...publicEntryViolations(config, context, owners, sourceFiles),
130
+ ...ownerImportViolations(config, context, owners),
128
131
  ];
129
132
  }
@@ -24,6 +24,9 @@ const HOOK_ROUTES = new Set([...MOUNTING_ROUTES, 'page']);
24
24
  const HOOK_NAME = /^use[A-Z0-9]/u;
25
25
  const HOOKS_SLOT = 'fe.hooks';
26
26
 
27
+ /** The client providers (fe.components.providers) wrap the mounted owner like a package shell; they are not a second owner. */
28
+ const PROVIDERS_SLOT = 'fe.components.providers';
29
+ const isFeatureOwner = target => target?.tier === 'feature' && target.slot !== PROVIDERS_SLOT;
27
30
  const isFunction = (ts, node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node);
28
31
  const jsxOf = (ts, node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
29
32
 
@@ -68,7 +71,7 @@ function reportRoute(violations, kit, file, stem, node, message, extra = {}) {
68
71
  function inspectMountingTag(tag, { graph, kit, ts, file, stem, checker, violations, state }) {
69
72
  if (ts.isIdentifier(tag) && /^[a-z]/u.test(tag.text)) {
70
73
  reportRoute(violations, kit, file, stem, tag, `<${tag.text}> is drawing in a ${stem} route file; a route file mounts one feature and draws nothing. Move the markup into the feature.`);
71
- } else if (kit.declarationsOf(checker, ts.isPropertyAccessExpression(tag) ? tag.name : tag).some(declaration => graph.files.get(kit.graphPath(declaration) ?? '')?.tier === 'feature')) {
74
+ } else if (kit.declarationsOf(checker, ts.isPropertyAccessExpression(tag) ? tag.name : tag).some(declaration => isFeatureOwner(graph.files.get(kit.graphPath(declaration) ?? '')))) {
72
75
  state.owners += 1;
73
76
  }
74
77
  }
@@ -1,24 +1,79 @@
1
- /** The value of a directive-prologue statement, or null once the prologue has ended. */
2
- function directiveValue(ts, sourceFile, statement) {
3
- const expressionStatement = ts?.isExpressionStatement
4
- ? ts.isExpressionStatement(statement)
5
- : Boolean(statement?.expression);
6
- if (!expressionStatement) return null;
7
- const expression = statement.expression;
8
- if (ts?.isStringLiteral) return ts.isStringLiteral(expression) ? expression.text : null;
9
- const raw = expression?.getText?.(sourceFile);
10
- const quote = raw?.[0];
11
- return typeof expression?.text === 'string' && (quote === "'" || quote === '"') && raw.at(-1) === quote
12
- ? expression.text
13
- : null;
1
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
2
+
3
+ /**
4
+ * The front end serves no API (owner ruling 2026-10-13, both editions, every app): it calls the back end through Apollo GraphQL and
5
+ * axios in modules/api and never runs server code of its own.
6
+ *
7
+ * - FE_SERVER_ACTION_FORBIDDEN: a `"use server"` directive anywhere in a front-end source file, in the file's directive prologue
8
+ * (a Server Action module) or in the prologue of any function body (an inline Server Action), apps and packages alike;
9
+ * - FE_ROUTE_HANDLER_FORBIDDEN: a route handler (`route.ts`, `route.js` and their module variants) under `apps/<app>/src/app/` other than the
10
+ * liveness probe `health/live/route.ts` and the file the slot `fe.route.callback` owns (the Supabase auth code exchange).
11
+ *
12
+ * The check is provider-independent: it runs for every front-end repository.
13
+ */
14
+ export const SERVER_ACTION_RULE_IDS = Object.freeze(['FE_SERVER_ACTION_FORBIDDEN', 'FE_ROUTE_HANDLER_FORBIDDEN']);
15
+
16
+ const SERVER_ACTION = 'FE_SERVER_ACTION_FORBIDDEN';
17
+ const ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
18
+ const CALLBACK_SLOT = 'fe.route.callback';
19
+ const ROUTE_FILE = /^apps\/([^/]+)\/src\/app\/(?:.+\/)?route\.[cm]?[jt]s$/u;
20
+
21
+ /** The statement nodes of a directive prologue: the leading string-literal expression statements. */
22
+ function prologue(ts, statements) {
23
+ const directives = [];
24
+ for (const statement of statements ?? []) {
25
+ if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) break;
26
+ directives.push(statement);
27
+ }
28
+ return directives;
29
+ }
30
+
31
+ const useServerIn = (ts, statements) => prologue(ts, statements).find(statement => statement.expression.text === 'use server') ?? null;
32
+
33
+ /** Every `"use server"` directive of one source file: the file-level one and each function-level one. */
34
+ function serverDirectives(ts, sourceFile) {
35
+ const found = [];
36
+ const fileLevel = useServerIn(ts, sourceFile.statements);
37
+ if (fileLevel) found.push({ node: fileLevel, level: 'file' });
38
+ const visit = node => {
39
+ // a function with a block body: declaration, expression, arrow, method, constructor or accessor
40
+ if (ts.isFunctionLike(node) && node.body && ts.isBlock(node.body)) {
41
+ const inline = useServerIn(ts, node.body.statements);
42
+ if (inline) found.push({ node: inline, level: 'function' });
43
+ }
44
+ ts.forEachChild(node, visit);
45
+ };
46
+ ts.forEachChild(sourceFile, visit);
47
+ return found;
48
+ }
49
+
50
+ function serverActionViolations(ts, file) {
51
+ return serverDirectives(ts, file.sourceFile).map(({ node, level }) => ({
52
+ ruleId: SERVER_ACTION, path: file.rel, ...sourceLocation(file.sourceFile, node), level,
53
+ message: `${file.rel} declares a ${level}-level "use server" directive: the front end runs no Server Action. Call the back end through a modules/api GraphQL operation (or a rest call) from an SWR hook, and move the server work into a be feature.`,
54
+ }));
55
+ }
56
+
57
+ function routeHandlerViolation(file) {
58
+ const match = ROUTE_FILE.exec(file.rel);
59
+ if (!match) return null;
60
+ if (file.rel === `apps/${match[1]}/src/app/health/live/route.ts` || file.slot === CALLBACK_SLOT) return null;
61
+ return {
62
+ ruleId: ROUTE_HANDLER, path: file.rel, line: 1, column: 1,
63
+ message: `${file.rel} is a front-end route handler outside health/live/route.ts and the ${CALLBACK_SLOT} slot: the front end serves no API. Webhooks, cron, public APIs and third-party callbacks belong to be features; the browser reaches the back end through modules/api.`,
64
+ };
14
65
  }
15
66
 
16
- /** True when a TypeScript source file declares `"use server"` in its directive prologue. */
17
- export function isServerActionModule(ts, sourceFile) {
18
- for (const statement of sourceFile?.statements ?? []) {
19
- const value = directiveValue(ts, sourceFile, statement);
20
- if (value === null) return false;
21
- if (value === 'use server') return true;
67
+ /** The FE machine check of both bans over every source file of the front-end program. */
68
+ export function checkServerActions({ graph, context }) {
69
+ const ts = context.ts;
70
+ const violations = [];
71
+ let routes = 0;
72
+ for (const file of graph.files.values()) {
73
+ violations.push(...serverActionViolations(ts, file));
74
+ const route = routeHandlerViolation(file);
75
+ if (ROUTE_FILE.test(file.rel)) routes += 1;
76
+ if (route) violations.push(route);
22
77
  }
23
- return false;
78
+ return { violations, coverage: { status: 'checked', files: graph.files.size, routes } };
24
79
  }
@@ -34,7 +34,7 @@ const typeFromSupabase = (checker, node) => {
34
34
  return false;
35
35
  };
36
36
 
37
- export const signatureFromSupabase = (checker, call) => {
37
+ const signatureFromSupabase = (checker, call) => {
38
38
  try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
39
39
  };
40
40
 
@@ -45,7 +45,7 @@ export const unwrap = (ts, node) => {
45
45
  return current;
46
46
  };
47
47
 
48
- export const isSupabaseValue = (kit, checker, node) => {
48
+ const isSupabaseValue = (kit, checker, node) => {
49
49
  const current = unwrap(kit.ts, node);
50
50
  if (!current) return false;
51
51
  if (importedFrom(kit, checker, current, SUPABASE_MODULES)) return true;