@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.
- package/CHANGELOG.md +19 -1
- package/README.md +1 -0
- package/client-boundary.mjs +66 -23
- package/docs/client-boundary.md +9 -9
- package/docs/hooks-folder.md +16 -4
- package/docs/hygiene.md +2 -2
- package/docs/transport.md +26 -26
- package/docs/type-safety.md +6 -6
- package/fe-stack.mjs +86 -0
- package/formatting.mjs +12 -6
- package/hooks-folder.mjs +73 -31
- package/hygiene.mjs +4 -4
- package/icon.mjs +25 -17
- package/index.mjs +2 -0
- package/lib/fe-stack.mjs +73 -0
- package/lib/scope.mjs +23 -2
- package/lib/why.mjs +23 -13
- package/next-conventions.mjs +4 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
- package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
- package/runtime/knowledge/hfs/slots.yaml +319 -179
- package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
- package/runtime/knowledge/sonar-gate.yaml +10 -2
- package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
- package/runtime/scripts/hfs/architecture/config.mjs +3 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
- package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
- package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
- package/runtime/scripts/hfs/architecture/index.mjs +2 -0
- package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
- package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
- package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
- package/runtime/scripts/hfs/edition-slots.mjs +13 -4
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
- package/served-locale.mjs +10 -13
- package/the-split.mjs +5 -2
- package/transport.mjs +81 -67
- 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
|
|
33
|
-
|
|
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
|
|
84
|
+
when: "A back-end call or the session"
|
|
82
85
|
write: >
|
|
83
|
-
fe
|
|
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/
|
|
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;
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
309
|
+
times or fills hooks/ with transport calls.
|
|
304
310
|
cases:
|
|
305
311
|
- id: case-1
|
|
306
|
-
when: "
|
|
307
|
-
write: "fe/apps/<app>/src/hooks/
|
|
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
|
|
313
|
-
write: "fe/apps/<app>/src/
|
|
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/
|
|
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
|
|
85
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
279
|
-
finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
127
|
-
...ownerImportViolations(config, context, owners
|
|
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) ?? '')
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
/**
|
|
17
|
-
export function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|