@starci/eslint-canon-fe 6.0.2 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +3 -5
- package/client-boundary.mjs +2 -5
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +18 -18
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +5 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +3 -68
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/project-graph.mjs +23 -0
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +321 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +95 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/common.mjs +44 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/project-graph.mjs +0 -0
- package/runtime/scripts/lib/project-rule.mjs +39 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +13 -17
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
schema: starci/knowledge-source@1
|
|
2
|
+
id: fe.folder
|
|
3
|
+
title: Folder
|
|
4
|
+
purpose: |
|
|
5
|
+
Given a piece of frontend code, which directory and which file name does it get? Apply the HFS frontend tree
|
|
6
|
+
(knowledge/hfs/README.md section 6, slots fe.*).
|
|
7
|
+
appliesTo:
|
|
8
|
+
- frontend
|
|
9
|
+
family: fe
|
|
10
|
+
provenance:
|
|
11
|
+
reviewedAt: 2026-09-29
|
|
12
|
+
sources:
|
|
13
|
+
- knowledge/hfs/README.md
|
|
14
|
+
- knowledge/hfs/slots.yaml
|
|
15
|
+
- https://nextjs.org/docs/app/getting-started/project-structure
|
|
16
|
+
- https://next-intl.dev/docs/routing
|
|
17
|
+
- packages/grammar/src/**
|
|
18
|
+
limitations: |
|
|
19
|
+
Folder rules place code by responsibility. They do not prove rendered behavior or accessibility. Inspect the
|
|
20
|
+
selected repository's actual source, manifests, aliases and lint and test configuration. Verify installed Grammar
|
|
21
|
+
exports at @starci/grammar/common.
|
|
22
|
+
rules:
|
|
23
|
+
- id: FE-FOLDER-1
|
|
24
|
+
title: Responsibility directories
|
|
25
|
+
kind: mandatory
|
|
26
|
+
hfsRules: [R01, R02, R59, R64]
|
|
27
|
+
requirement: |
|
|
28
|
+
Every frontend repository is an apps/<app>/ monorepo even with one app, named by its role (for example web), and
|
|
29
|
+
all product source lives under apps/<app>/src. A repository-root src/ does not exist. Each Next source root
|
|
30
|
+
(apps/<app>/src) uses app/ for framework adapters; features/{pages,layouts,overlays} for route-facing product
|
|
31
|
+
composition; components/{blocks,composites,branches,leaves} for product visuals; hooks/<domain> for React hooks;
|
|
32
|
+
and modules/<capability> for cohesive reusable technical and domain capabilities. Every app has the modules api,
|
|
33
|
+
config, i18n and routes, and one brand.css under modules/brand when it sets brand tokens. No other empty role
|
|
34
|
+
folder is required. Grammar remains a separate product-agnostic package. The only files that may sit directly in the
|
|
35
|
+
source root (the directory holding app/) are the framework-pinned root files listed in frameworkPinnedRootFiles
|
|
36
|
+
below: Next.js loads them from that exact place. Each is a thin framework adapter whose internal imports enter
|
|
37
|
+
modules or a feature public entry only; every other rule still applies to it, except that the export names the
|
|
38
|
+
framework mandates there (frameworkPinnedRootExports) keep their framework spelling. HFS uses proxy.ts for locale
|
|
39
|
+
routing; a middleware.* file is refused on Next 16 and later by the Next conventions check and the slot
|
|
40
|
+
fe.source-root-pinned, even though the machine list names it as a file Next loads. Any other file at the source
|
|
41
|
+
root, including a helper folder beside a pinned file, has no owner and is refused.
|
|
42
|
+
frameworkPinnedRootFiles:
|
|
43
|
+
# Machine list read by scripts/checks/architecture/framework-pinned.mjs (FE_SOURCE_LAYOUT_INVALID accepts exactly
|
|
44
|
+
# these basenames directly in a Next source root; FE_TIER_DIRECTION keeps them thin). Exact names,
|
|
45
|
+
# case-sensitive, no globs. It lists what the framework loads from the source root. Which of them a repository may
|
|
46
|
+
# keep is decided by the slot fe.source-root-pinned and FE_NEXT_CONVENTIONS, not by this list.
|
|
47
|
+
# Machine list read by scripts/checks/architecture/framework-pinned.mjs (FE_SOURCE_LAYOUT_INVALID
|
|
48
|
+
# accepts exactly these basenames directly in a Next source root;
|
|
49
|
+
# their imports follow the FE_TIER_DIRECTION matrix). Exact names, case-sensitive, no globs. Supervisor rulings for nivo
|
|
50
|
+
# wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4 and inc-846867b9a34e (proxy is the Next 16 name of
|
|
51
|
+
# middleware, listed so the list survives the upgrade).
|
|
52
|
+
- middleware.ts
|
|
53
|
+
- middleware.js
|
|
54
|
+
- middleware.mjs
|
|
55
|
+
- proxy.ts
|
|
56
|
+
- proxy.js
|
|
57
|
+
- proxy.mjs
|
|
58
|
+
- instrumentation.ts
|
|
59
|
+
- instrumentation.js
|
|
60
|
+
- instrumentation.mjs
|
|
61
|
+
- instrumentation-client.ts
|
|
62
|
+
- instrumentation-client.js
|
|
63
|
+
- instrumentation-client.mjs
|
|
64
|
+
- next-env.d.ts
|
|
65
|
+
frameworkPinnedRootExports:
|
|
66
|
+
# Export names the framework mandates in a pinned root file, keyed by file stem (read by
|
|
67
|
+
# scripts/checks/architecture/framework-pinned.mjs). FE_SOURCE_NAME_SHAPE accepts exactly these names in exactly
|
|
68
|
+
# these files when they sit directly in a Next source root; everywhere else the name-shape rule is unchanged.
|
|
69
|
+
middleware: [config, middleware, default]
|
|
70
|
+
proxy: [config, proxy, default]
|
|
71
|
+
instrumentation: [register, onRequestError]
|
|
72
|
+
instrumentation-client: [onRouterTransitionStart]
|
|
73
|
+
rationale: |
|
|
74
|
+
One fixed source root per app keeps every repository the same shape while preventing app helpers, component-local
|
|
75
|
+
hooks and feature-local transport buckets from becoming undeclared owners.
|
|
76
|
+
cases:
|
|
77
|
+
- id: case-1
|
|
78
|
+
when: "A routed authentication scenario"
|
|
79
|
+
write: "apps/<app>/src/features/pages/AuthenticationPage/index.tsx"
|
|
80
|
+
- id: case-2
|
|
81
|
+
when: "A reusable API client, reader or session capability"
|
|
82
|
+
write: >
|
|
83
|
+
apps/<app>/src/modules/api/ or apps/<app>/src/modules/session/ with one explicit public index.ts entry
|
|
84
|
+
- id: case-3
|
|
85
|
+
when: "A reusable product visual without scenario ownership"
|
|
86
|
+
write: "apps/<app>/src/components/{blocks,composites,branches,leaves}/<Name>/index.tsx"
|
|
87
|
+
- id: case-4
|
|
88
|
+
when: "A React hook"
|
|
89
|
+
write: "apps/<app>/src/hooks/<domain>/useAutoScroll.ts; built-in React hook calls may remain in visuals"
|
|
90
|
+
- id: case-5
|
|
91
|
+
kind: exception
|
|
92
|
+
when: "Next.js only loads the file from the source root (proxy, instrumentation, instrumentation-client, next-env.d.ts)"
|
|
93
|
+
write: >
|
|
94
|
+
apps/<app>/src/proxy.ts beside apps/<app>/src/app/ as a thin adapter; locale routing logic lives in
|
|
95
|
+
apps/<app>/src/modules/i18n/ and the adapter imports it; the names the framework mandates there
|
|
96
|
+
(frameworkPinnedRootExports, for example export const config = { matcher }) keep their framework spelling
|
|
97
|
+
- id: case-6
|
|
98
|
+
when: "Any other file or folder directly under the source root (apps/<app>/src/i18n/request.ts, apps/<app>/src/config.ts), and a repository-root src/"
|
|
99
|
+
write: >
|
|
100
|
+
Move it to its owner, for example apps/<app>/src/modules/i18n/request.ts, and point framework configuration
|
|
101
|
+
(apps/<app>/next.config.ts) at the new path
|
|
102
|
+
- id: case-7
|
|
103
|
+
when: "The per-slot data-status recipe"
|
|
104
|
+
write: >
|
|
105
|
+
apps/<app>/src/components/composites/SlotView/index.tsx, apps/<app>/src/modules/slot/index.ts (Slot, toSlot,
|
|
106
|
+
SlotLabels) and apps/<app>/src/hooks/slot/useSlotLabels.ts, one copy each (examples/shape-slot)
|
|
107
|
+
verification:
|
|
108
|
+
automated:
|
|
109
|
+
- HFS_SLOT_UNDECLARED
|
|
110
|
+
- HFS_SLOT_REQUIRED_MISSING
|
|
111
|
+
- FE_SOURCE_LAYOUT_INVALID
|
|
112
|
+
- FE_NEXT_CONVENTIONS
|
|
113
|
+
- FE_TIER_DIRECTION
|
|
114
|
+
- FE_CUSTOM_HOOK_LOCATION
|
|
115
|
+
- HFS_APPS_REQUIRED
|
|
116
|
+
- HFS_ROOT_SRC_FORBIDDEN_FE
|
|
117
|
+
manual:
|
|
118
|
+
- Confirm the unit's responsibility and public entry, rather than assigning it by consumer count.
|
|
119
|
+
relatedExamples:
|
|
120
|
+
- connected-block
|
|
121
|
+
|
|
122
|
+
- id: FE-FOLDER-2
|
|
123
|
+
title: Discoverable files for one unit
|
|
124
|
+
kind: mandatory
|
|
125
|
+
hfsRules: [R02, R55, R65]
|
|
126
|
+
requirement: |
|
|
127
|
+
A split unit (a connected block, and a feature page, layout or overlay) uses index.tsx for the connected X and
|
|
128
|
+
sibling component.tsx for the pure XBase, its XBaseProps and xDefaultState, plus XState when the shape is its own union
|
|
129
|
+
(a shape that is a domain status is typed with that status, never a second name for it). Only that index.tsx and the
|
|
130
|
+
unit's specs import component.tsx. Pure blocks, composites, branches and leaves use index.tsx only and never gain an
|
|
131
|
+
empty twin. classNames.ts is present only when the unit owns class strings. Budgets: component.tsx 300 lines,
|
|
132
|
+
connected index.tsx 200 lines, at most 6 data hooks and 6 useState per connected unit.
|
|
133
|
+
rationale: |
|
|
134
|
+
Stable basenames make class, tests and the connected and presentational split discoverable without forwarding
|
|
135
|
+
twins, and budgets keep a unit reviewable.
|
|
136
|
+
cases:
|
|
137
|
+
- id: case-1
|
|
138
|
+
when: "Connected block with product-world lifecycle"
|
|
139
|
+
write: "blocks/CatalogBlock/{index.tsx,component.tsx}; every nonempty index render reaches the sibling"
|
|
140
|
+
- id: case-2
|
|
141
|
+
when: "Feature page, layout or overlay"
|
|
142
|
+
write: >
|
|
143
|
+
features/pages/OperatePage/{index.tsx,component.tsx,classNames.ts}, features/layouts/WorkspaceLayout/…,
|
|
144
|
+
features/overlays/SendHandoffOverlay/… (examples/shape-slot)
|
|
145
|
+
- id: case-3
|
|
146
|
+
when: "Single visual unit"
|
|
147
|
+
write: "leaves/ButtonStateSample/{classNames.ts,index.tsx}; no component.tsx twin without a separate world owner"
|
|
148
|
+
- id: case-4
|
|
149
|
+
when: "Custom hook or reusable module"
|
|
150
|
+
write: "hooks/<domain>/useX.ts or modules/<capability>/...; do not create visual twins"
|
|
151
|
+
verification:
|
|
152
|
+
automated:
|
|
153
|
+
- class-names-in-colocated-file
|
|
154
|
+
- no-inline-class-name
|
|
155
|
+
- FE_CONNECTED_BLOCK_RENDER_PAIR
|
|
156
|
+
- FE_SIZE_AND_STATE_BUDGET
|
|
157
|
+
- HFS_SIZE_GROWTH
|
|
158
|
+
- starci-fe/base-import-pair
|
|
159
|
+
manual:
|
|
160
|
+
- Confirm each file owns a real responsibility; do not add a component.tsx twin by habit.
|
|
161
|
+
relatedExamples:
|
|
162
|
+
- connected-block
|
|
163
|
+
|
|
164
|
+
- id: FE-FOLDER-3
|
|
165
|
+
title: Route files adapt to one visual owner and every app has its boundaries
|
|
166
|
+
kind: mandatory
|
|
167
|
+
hfsRules: [R53, R54, R55, R57]
|
|
168
|
+
requirement: |
|
|
169
|
+
Route files under app/[locale]/ (page, layout, template, loading, not-found) are server components. A route may
|
|
170
|
+
resolve framework server inputs (params, searchParams, headers, cookies), then mounts one page-level visual owner.
|
|
171
|
+
A route whose complete responsibility is redirect or notFound mounts none. A route file holds no inline component, no
|
|
172
|
+
drawing decision and no hook, and a redirect from an old URL lives in next.config redirects(), never in a page. Every app has
|
|
173
|
+
app/global-error.tsx, app/[locale]/layout.tsx, app/[locale]/error.tsx and app/[locale]/not-found.tsx, and a loading.tsx
|
|
174
|
+
for each route group that reads data. Each page exports generateMetadata. force-dynamic on a client page is forbidden.
|
|
175
|
+
rationale: |
|
|
176
|
+
Keeping default exports in the App Router layer prevents components from becoming route-coupled, and mandatory
|
|
177
|
+
boundaries turn a render failure into a recoverable screen instead of a blank page.
|
|
178
|
+
cases:
|
|
179
|
+
- id: case-1
|
|
180
|
+
when: "A route"
|
|
181
|
+
write: >
|
|
182
|
+
app/[locale]/authentication/page.tsx: const AuthenticationRoute = async () => <AuthenticationPage />, then export
|
|
183
|
+
default AuthenticationRoute
|
|
184
|
+
- id: case-2
|
|
185
|
+
kind: exception
|
|
186
|
+
when: "Redirect or notFound adapter"
|
|
187
|
+
write: "Resolve framework inputs and redirect or terminate without a visual owner."
|
|
188
|
+
- id: case-3
|
|
189
|
+
when: "Global app files"
|
|
190
|
+
write: >
|
|
191
|
+
app/globals.css, app/global-error.tsx, app/[locale]/error.tsx, app/[locale]/not-found.tsx, app/sitemap.ts,
|
|
192
|
+
app/robots.ts, app/health/{live,ready}/route.ts
|
|
193
|
+
verification:
|
|
194
|
+
automated:
|
|
195
|
+
- FE_ERROR_BOUNDARY_MISSING
|
|
196
|
+
- FE_ROUTE_FILES_THIN
|
|
197
|
+
- FE_CLIENT_BOUNDARY
|
|
198
|
+
- FE_OWNER_REACHABLE
|
|
199
|
+
- FE_TIER_DIRECTION
|
|
200
|
+
manual:
|
|
201
|
+
- Confirm visual routes have one page owner and redirect-only routes have no visual tree.
|
|
202
|
+
relatedExamples: []
|
|
203
|
+
|
|
204
|
+
- id: FE-FOLDER-4
|
|
205
|
+
title: Hooks are hooks; readers and transport live in modules
|
|
206
|
+
kind: mandatory
|
|
207
|
+
hfsRules: [R56, R50, R49]
|
|
208
|
+
requirement: |
|
|
209
|
+
hooks/<domain>/ holds React hooks (use*.ts, one hook per file) and exactly one <domain>.shared.ts for shared
|
|
210
|
+
non-hook helpers of that domain (key builders, token readers). A file in hooks/ that is not a hook and is not the shared
|
|
211
|
+
file is a finding. Server-side readers (fetch and map, no React) live in modules/api/<domain>/read-*.ts and are
|
|
212
|
+
wrapped in React cache() when several blocks call them in one request. Generic client plumbing, the Outcome type,
|
|
213
|
+
the contract copy and generated wire types live in modules/api; environment reading lives in modules/config; every
|
|
214
|
+
href builder lives in modules/routes; locale routing lives in modules/i18n; colour values live in modules/brand.
|
|
215
|
+
A helper name defined twice across hook files is a finding.
|
|
216
|
+
rationale: |
|
|
217
|
+
A rule that says what a hooks folder is also says where everything else goes, so nobody copies a helper sixteen
|
|
218
|
+
times or fills hooks/ with server fetch functions.
|
|
219
|
+
cases:
|
|
220
|
+
- id: case-1
|
|
221
|
+
when: "Authentication request lifecycle"
|
|
222
|
+
write: "apps/<app>/src/hooks/authentication/useAuthentication.ts"
|
|
223
|
+
- id: case-2
|
|
224
|
+
when: "A helper shared by the hooks of one domain"
|
|
225
|
+
write: "apps/<app>/src/hooks/sales/sales.shared.ts, for example useSalesAccessToken's token reader"
|
|
226
|
+
- id: case-3
|
|
227
|
+
when: "A server read used by several blocks"
|
|
228
|
+
write: "apps/<app>/src/modules/api/sales/read-sales-summary.ts, exported through modules/api/index.ts"
|
|
229
|
+
- id: case-4
|
|
230
|
+
when: "The API client"
|
|
231
|
+
write: "apps/<app>/src/modules/api/client.ts and apps/<app>/src/modules/api/outcome.ts"
|
|
232
|
+
- id: case-5
|
|
233
|
+
when: "Intrinsic custom helper"
|
|
234
|
+
write: "apps/<app>/src/hooks/ui/useAutoScroll.ts; direct useRef and useState calls may remain in their visual owner"
|
|
235
|
+
verification:
|
|
236
|
+
automated:
|
|
237
|
+
- FE_HOOKS_ARE_HOOKS
|
|
238
|
+
- FE_CUSTOM_HOOK_LOCATION
|
|
239
|
+
- HFS_DUPLICATE_CODE
|
|
240
|
+
- FE_TIER_DIRECTION
|
|
241
|
+
manual:
|
|
242
|
+
- Confirm each lifecycle has one feature owner and transport mechanics have one module owner.
|
|
243
|
+
relatedExamples:
|
|
244
|
+
- connected-block
|
|
245
|
+
|
|
246
|
+
- id: FE-FOLDER-5
|
|
247
|
+
title: Package unit
|
|
248
|
+
kind: mandatory
|
|
249
|
+
hfsRules: [R63]
|
|
250
|
+
requirement: |
|
|
251
|
+
A shared package is packages/<pkg>/ with package.json (explicit exports), src/index.ts, tsconfig.json and colocated
|
|
252
|
+
specs, built to dist. Grammar components live under packages/grammar/src/core/<tier>/<Name>/ with collocated class
|
|
253
|
+
strings and specs; family entries and built-output proof stay at the package roots. See packages.yaml for the shared
|
|
254
|
+
UI package shape.
|
|
255
|
+
rationale: |
|
|
256
|
+
Package layout separates primitives from app composition and keeps public entry verification against dist.
|
|
257
|
+
cases:
|
|
258
|
+
- id: case-1
|
|
259
|
+
when: "A Grammar component"
|
|
260
|
+
write: "packages/grammar/src/core/<primitive|composite|branch|composition>/<Name>/index.tsx"
|
|
261
|
+
- id: case-2
|
|
262
|
+
when: "A Grammar component's class strings and spec"
|
|
263
|
+
write: "sibling classNames.ts and index.spec.tsx"
|
|
264
|
+
- id: case-3
|
|
265
|
+
when: "Built-output proof"
|
|
266
|
+
write: "common/index.test.mjs, core/index.test.mjs, package-boundary.test.mjs (node:test against dist/)"
|
|
267
|
+
verification:
|
|
268
|
+
automated:
|
|
269
|
+
- FE_PACKAGE_SHAPE
|
|
270
|
+
manual:
|
|
271
|
+
- Verify public app imports use @starci/grammar/common and installed exports, never the package root.
|
|
272
|
+
relatedExamples: []
|
|
273
|
+
|
|
274
|
+
- id: FE-FOLDER-6
|
|
275
|
+
title: Keep units cohesive
|
|
276
|
+
kind: mandatory
|
|
277
|
+
hfsRules: [R49, R21]
|
|
278
|
+
requirement: |
|
|
279
|
+
A unit folder contains files that change for the same responsibility. Do not create generic helpers/ or utils/
|
|
280
|
+
buckets inside components, read deployment configuration in presentation, or split files solely to meet a count.
|
|
281
|
+
Collocated subordinate renders and pure helpers are valid when they remain private to the owner; a separately stable
|
|
282
|
+
responsibility receives its own named owner.
|
|
283
|
+
rationale: |
|
|
284
|
+
Cohesion prevents helper sprawl and duplicated configuration while avoiding one-component-per-folder ceremony that
|
|
285
|
+
has no dependency boundary.
|
|
286
|
+
cases:
|
|
287
|
+
- id: case-1
|
|
288
|
+
when: "A small subordinate render is private to one owner"
|
|
289
|
+
write: "Keep it local with an unexported contract; extract it when it gains a stable public responsibility."
|
|
290
|
+
- id: case-2
|
|
291
|
+
when: "Class strings in component.tsx"
|
|
292
|
+
write: "Never; they go to classNames.ts (lint class-names-in-colocated-file, no-inline-class-name)"
|
|
293
|
+
- id: case-3
|
|
294
|
+
when: "A helpers/ or utils/ folder"
|
|
295
|
+
write: "Do not use it to hide mixed feature, transport or visual responsibilities."
|
|
296
|
+
- id: case-4
|
|
297
|
+
when: "A deployment constant"
|
|
298
|
+
write: >
|
|
299
|
+
Never. process.env.NEXT_PUBLIC_* is read only in apps/<app>/src/modules/config and reached through that module,
|
|
300
|
+
with no localhost fallback; a missing value in production throws when the module loads.
|
|
301
|
+
verification:
|
|
302
|
+
automated:
|
|
303
|
+
- FE_ENV_OWNER
|
|
304
|
+
- class-names-in-colocated-file
|
|
305
|
+
- no-inline-class-name
|
|
306
|
+
- no-helper-folder-in-components
|
|
307
|
+
manual:
|
|
308
|
+
- Confirm extraction is justified by ownership or lifecycle rather than file length or count.
|
|
309
|
+
relatedExamples: []
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isInside } from './config.mjs';
|
|
3
|
+
import { slotAdmitsFile } from '../../lib/hfs-allows.mjs';
|
|
4
|
+
import { reachableViolation, relativePath, sourceLocation } from './typescript.mjs';
|
|
5
|
+
|
|
6
|
+
const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
|
|
7
|
+
const FORBIDDEN_DECLARATION = /(?:Service|Provider|Resolver|Controller|Handler|Repository|Entity|UseCase|Command|Query|Listener|Consumer|Processor)$/;
|
|
8
|
+
const FORBIDDEN_DECORATORS = new Set(['Controller', 'Resolver', 'Injectable', 'Processor', 'WebSocketGateway']);
|
|
9
|
+
const TRANSPORT_PACKAGES = /^(?:@nestjs\/(?:graphql|microservices|platform-[^/]+|websockets)(?:\/|$)|@apollo\/|apollo-server(?:\/|$)|express(?:\/|$)|fastify(?:\/|$)|graphql(?:\/|$)|class-validator(?:\/|$)|class-transformer(?:\/|$))/;
|
|
10
|
+
const NEST_COMMON_TRANSPORT = new Set(['Controller', 'Get', 'Post', 'Put', 'Patch', 'Delete', 'Options', 'Head', 'Body', 'Param', 'Query', 'Req', 'Request', 'Res', 'Response', 'Headers', 'Header', 'HttpCode', 'Redirect', 'Render', 'Sse', 'UploadedFile', 'UploadedFiles', 'UseGuards', 'UseInterceptors', 'UsePipes']);
|
|
11
|
+
|
|
12
|
+
function absolute(root, relative) {
|
|
13
|
+
return path.resolve(root, ...relative.split('/'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function roots(root, relatives) {
|
|
17
|
+
return relatives.map(relative => absolute(root, relative));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function insideAny(candidates, fileName) {
|
|
21
|
+
return candidates.some(root => isInside(root, fileName));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function insideFeatureLayer(featureRoots, fileName, layer) {
|
|
25
|
+
return featureRoots.some(root => isInside(root, fileName)
|
|
26
|
+
&& relativePath(root, fileName).split('/').some(segment => segment.toLowerCase() === layer));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function appSource(config, fileName, excludedRoots = []) {
|
|
30
|
+
if (excludedRoots.some(excluded => isInside(excluded, fileName))) return null;
|
|
31
|
+
for (const appRoot of roots(config.root, config.backend.apps)) {
|
|
32
|
+
if (!isInside(appRoot, fileName)) continue;
|
|
33
|
+
const parts = relativePath(appRoot, fileName).split('/');
|
|
34
|
+
const sourceIndex = parts.indexOf('src');
|
|
35
|
+
if (sourceIndex >= 0) return { appRoot, relative: parts.slice(sourceIndex + 1).join('/') };
|
|
36
|
+
// An app root may name its src/ directly (apps/<app>/src), but the repository src/ itself is
|
|
37
|
+
// never an app root: HFS composition lives under apps/<app> only.
|
|
38
|
+
const appRootRelative = relativePath(config.root, appRoot).split('/');
|
|
39
|
+
if (path.basename(appRoot).toLowerCase() === 'src' && appRootRelative[0] === 'apps' && appRootRelative.length > 1) {
|
|
40
|
+
return { appRoot, relative: parts.join('/') };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decoratorName(ts, decorator) {
|
|
48
|
+
const expression = decorator.expression;
|
|
49
|
+
const called = ts.isCallExpression(expression) ? expression.expression : expression;
|
|
50
|
+
if (ts.isIdentifier(called)) return called.text;
|
|
51
|
+
return ts.isPropertyAccessExpression(called) ? called.name.text : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function roleEvidence(ts, sourceFile) {
|
|
55
|
+
let found = null;
|
|
56
|
+
const visit = node => {
|
|
57
|
+
if (found) return;
|
|
58
|
+
const decorators = ts.canHaveDecorators?.(node) ? ts.getDecorators(node) ?? [] : node.decorators ?? [];
|
|
59
|
+
const forbidden = decorators.find(item => FORBIDDEN_DECORATORS.has(decoratorName(ts, item)));
|
|
60
|
+
if (forbidden) {
|
|
61
|
+
found = { node: forbidden, detail: `@${decoratorName(ts, forbidden)}` };
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if ((ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) && node.name && FORBIDDEN_DECLARATION.test(node.name.text)) {
|
|
65
|
+
found = { node: node.name, detail: node.name.text };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
ts.forEachChild(node, visit);
|
|
69
|
+
};
|
|
70
|
+
visit(sourceFile);
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function transportFrameworkEvidence(ts, sourceFile, checker) {
|
|
75
|
+
const found = [];
|
|
76
|
+
const namespaceUsages = binding => {
|
|
77
|
+
const usages = [];
|
|
78
|
+
const alias = binding.text;
|
|
79
|
+
const bindingSymbol = checker?.getSymbolAtLocation(binding);
|
|
80
|
+
const isAlias = node => ts.isIdentifier(node) && node.text === alias
|
|
81
|
+
&& (!bindingSymbol || checker?.getSymbolAtLocation(node) === bindingSymbol);
|
|
82
|
+
const visit = node => {
|
|
83
|
+
if (ts.isPropertyAccessExpression(node) && isAlias(node.expression)
|
|
84
|
+
&& NEST_COMMON_TRANSPORT.has(node.name.text)) usages.push({ node: node.name, detail: node.name.text });
|
|
85
|
+
if (ts.isElementAccessExpression(node) && isAlias(node.expression)) {
|
|
86
|
+
const selected = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
87
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) usages.push({ node: node.argumentExpression, detail: selected ?? 'computed @nestjs/common namespace access' });
|
|
88
|
+
}
|
|
89
|
+
if (ts.isVariableDeclaration(node) && isAlias(node.initializer)) {
|
|
90
|
+
if (!ts.isObjectBindingPattern(node.name)) {
|
|
91
|
+
usages.push({ node: node.name, detail: 'escaped @nestjs/common namespace access' });
|
|
92
|
+
} else {
|
|
93
|
+
for (const element of node.name.elements) {
|
|
94
|
+
const selected = element.dotDotDotToken
|
|
95
|
+
? null
|
|
96
|
+
: ts.isIdentifier(element.propertyName ?? element.name)
|
|
97
|
+
? (element.propertyName ?? element.name).text
|
|
98
|
+
: ts.isStringLiteralLike(element.propertyName)
|
|
99
|
+
? element.propertyName.text
|
|
100
|
+
: null;
|
|
101
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) {
|
|
102
|
+
usages.push({ node: element, detail: selected ?? 'computed @nestjs/common namespace destructuring' });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
ts.forEachChild(node, visit);
|
|
108
|
+
};
|
|
109
|
+
visit(sourceFile);
|
|
110
|
+
return usages;
|
|
111
|
+
};
|
|
112
|
+
for (const statement of sourceFile.statements) {
|
|
113
|
+
const importEqualsSpecifier = ts.isImportEqualsDeclaration(statement)
|
|
114
|
+
&& ts.isExternalModuleReference(statement.moduleReference)
|
|
115
|
+
&& statement.moduleReference.expression
|
|
116
|
+
&& ts.isStringLiteralLike(statement.moduleReference.expression)
|
|
117
|
+
? statement.moduleReference.expression
|
|
118
|
+
: null;
|
|
119
|
+
const importSpecifier = ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)
|
|
120
|
+
? statement.moduleSpecifier
|
|
121
|
+
: importEqualsSpecifier;
|
|
122
|
+
if (!importSpecifier) continue;
|
|
123
|
+
const specifier = importSpecifier.text;
|
|
124
|
+
if (TRANSPORT_PACKAGES.test(specifier)) {
|
|
125
|
+
found.push({ node: importSpecifier, specifier, detail: specifier });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (specifier !== '@nestjs/common') continue;
|
|
129
|
+
if (ts.isImportEqualsDeclaration(statement)) {
|
|
130
|
+
found.push(...namespaceUsages(statement.name).map(item => ({ ...item, specifier })));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!statement.importClause) continue;
|
|
134
|
+
const bindings = statement.importClause.namedBindings;
|
|
135
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
136
|
+
found.push(...namespaceUsages(bindings.name).map(item => ({ ...item, specifier })));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
140
|
+
for (const element of bindings.elements) {
|
|
141
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
142
|
+
if (NEST_COMMON_TRANSPORT.has(imported)) found.push({ node: element, specifier, detail: imported });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const visitRequire = node => {
|
|
146
|
+
const requiredSpecifier = call => ts.isCallExpression(call)
|
|
147
|
+
&& ts.isIdentifier(call.expression) && call.expression.text === 'require'
|
|
148
|
+
&& call.arguments.length === 1 && ts.isStringLiteralLike(call.arguments[0])
|
|
149
|
+
? call.arguments[0].text
|
|
150
|
+
: null;
|
|
151
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && ts.isCallExpression(node.expression)) {
|
|
152
|
+
const specifier = requiredSpecifier(node.expression);
|
|
153
|
+
const selected = ts.isPropertyAccessExpression(node)
|
|
154
|
+
? node.name.text
|
|
155
|
+
: ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
156
|
+
if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
|
|
157
|
+
found.push({ node: ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
|
|
158
|
+
} else if (specifier === '@nestjs/common' && (selected === null || NEST_COMMON_TRANSPORT.has(selected))) {
|
|
159
|
+
found.push({ node: ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression, specifier, detail: selected ?? 'computed @nestjs/common require access' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)
|
|
163
|
+
&& requiredSpecifier(node.initializer)) {
|
|
164
|
+
const specifier = requiredSpecifier(node.initializer);
|
|
165
|
+
if (TRANSPORT_PACKAGES.test(specifier)) {
|
|
166
|
+
found.push({ node: node.initializer.arguments[0], specifier, detail: specifier });
|
|
167
|
+
} else if (specifier === '@nestjs/common') {
|
|
168
|
+
if (ts.isIdentifier(node.name)) {
|
|
169
|
+
found.push(...namespaceUsages(node.name).map(item => ({ ...item, specifier })));
|
|
170
|
+
} else if (ts.isObjectBindingPattern(node.name)) {
|
|
171
|
+
for (const element of node.name.elements) {
|
|
172
|
+
const selected = element.dotDotDotToken
|
|
173
|
+
? null
|
|
174
|
+
: ts.isIdentifier(element.propertyName ?? element.name)
|
|
175
|
+
? (element.propertyName ?? element.name).text
|
|
176
|
+
: ts.isStringLiteralLike(element.propertyName)
|
|
177
|
+
? element.propertyName.text
|
|
178
|
+
: null;
|
|
179
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) found.push({ node: element, specifier, detail: selected ?? 'computed @nestjs/common require destructuring' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
ts.forEachChild(node, visitRequire);
|
|
185
|
+
};
|
|
186
|
+
visitRequire(sourceFile);
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function importedSurface(ts, declaration) {
|
|
191
|
+
if (!ts.isImportDeclaration(declaration)) return null;
|
|
192
|
+
const clause = declaration.importClause;
|
|
193
|
+
if (!clause) return null;
|
|
194
|
+
const names = new Set(clause.name ? ['default'] : []);
|
|
195
|
+
const bindings = clause.namedBindings;
|
|
196
|
+
if (bindings && ts.isNamespaceImport(bindings)) return null;
|
|
197
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
198
|
+
for (const element of bindings.elements) names.add(element.propertyName?.text ?? element.name.text);
|
|
199
|
+
}
|
|
200
|
+
return names;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function selectedReexportSurface(ts, declaration, selected) {
|
|
204
|
+
if (!ts.isExportDeclaration(declaration)) return undefined;
|
|
205
|
+
if (!declaration.exportClause) return selected;
|
|
206
|
+
if (ts.isNamespaceExport(declaration.exportClause)) {
|
|
207
|
+
return selected === null || selected.has(declaration.exportClause.name.text) ? null : undefined;
|
|
208
|
+
}
|
|
209
|
+
if (!ts.isNamedExports(declaration.exportClause)) return undefined;
|
|
210
|
+
const next = new Set();
|
|
211
|
+
for (const element of declaration.exportClause.elements) {
|
|
212
|
+
if (selected === null || selected.has(element.name.text)) next.add(element.propertyName?.text ?? element.name.text);
|
|
213
|
+
}
|
|
214
|
+
return next.size ? next : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function externalTransportReexport(ts, sourceFile, selected) {
|
|
218
|
+
const imported = new Map();
|
|
219
|
+
for (const statement of sourceFile.statements) {
|
|
220
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || !statement.importClause) continue;
|
|
221
|
+
const specifier = statement.moduleSpecifier.text;
|
|
222
|
+
const bindings = statement.importClause.namedBindings;
|
|
223
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
224
|
+
for (const element of bindings.elements) imported.set(element.name.text, { specifier, imported: element.propertyName?.text ?? element.name.text });
|
|
225
|
+
} else if (bindings && ts.isNamespaceImport(bindings)) imported.set(bindings.name.text, { specifier, imported: null });
|
|
226
|
+
}
|
|
227
|
+
for (const statement of sourceFile.statements) {
|
|
228
|
+
if (!ts.isExportDeclaration(statement)) continue;
|
|
229
|
+
const clause = statement.exportClause;
|
|
230
|
+
const specifier = ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
|
|
231
|
+
if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) {
|
|
232
|
+
if (!clause) {
|
|
233
|
+
if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
|
|
234
|
+
return { node: statement.moduleSpecifier, specifier, detail: selected === null ? `unbounded ${specifier} re-export` : `re-exported ${[...selected].join(', ')}` };
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (ts.isNamespaceExport(clause)) {
|
|
239
|
+
if (selected === null || selected.has(clause.name.text)) return { node: clause.name, specifier, detail: `namespace re-export from ${specifier}` };
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!ts.isNamedExports(clause)) continue;
|
|
243
|
+
for (const element of clause.elements) {
|
|
244
|
+
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
245
|
+
const original = element.propertyName?.text ?? element.name.text;
|
|
246
|
+
if (specifier !== '@nestjs/common' || NEST_COMMON_TRANSPORT.has(original)) return { node: element, specifier, detail: `${element.name.text} re-exported from ${specifier}` };
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (specifier || !clause || !ts.isNamedExports(clause)) continue;
|
|
251
|
+
for (const element of clause.elements) {
|
|
252
|
+
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
253
|
+
const local = element.propertyName?.text ?? element.name.text;
|
|
254
|
+
const binding = imported.get(local);
|
|
255
|
+
if (!binding) continue;
|
|
256
|
+
if (binding.specifier !== '@nestjs/common' && !TRANSPORT_PACKAGES.test(binding.specifier)) continue;
|
|
257
|
+
if (binding.specifier === '@nestjs/common' && (binding.imported === null || !NEST_COMMON_TRANSPORT.has(binding.imported))) continue;
|
|
258
|
+
return { node: element, specifier: binding.specifier, detail: `${element.name.text} re-exported from ${binding.specifier}` };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function reexportedTransportEvidence(ts, context, sourceFiles, firstEdge) {
|
|
265
|
+
const queue = [{ file: firstEdge.to, selected: importedSurface(ts, firstEdge.declaration), chain: [firstEdge.from, firstEdge.to] }];
|
|
266
|
+
const visited = new Set();
|
|
267
|
+
while (queue.length) {
|
|
268
|
+
const current = queue.shift();
|
|
269
|
+
const key = `${current.file}\0${current.selected === null ? '*' : [...current.selected].sort().join(',')}`;
|
|
270
|
+
if (visited.has(key)) continue;
|
|
271
|
+
visited.add(key);
|
|
272
|
+
const sourceFile = sourceFiles.get(path.resolve(current.file));
|
|
273
|
+
if (!sourceFile) continue;
|
|
274
|
+
const evidence = externalTransportReexport(ts, sourceFile, current.selected);
|
|
275
|
+
if (evidence) return { evidence, chain: current.chain };
|
|
276
|
+
for (const edge of context.edges.get(current.file) ?? []) {
|
|
277
|
+
if (!edge.reexport) continue;
|
|
278
|
+
const selected = selectedReexportSurface(ts, edge.declaration, current.selected);
|
|
279
|
+
if (selected !== undefined) queue.push({ file: edge.to, selected, chain: [...current.chain, edge.to] });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function finding(config, edge, ruleId, message, chain) {
|
|
286
|
+
return {
|
|
287
|
+
ruleId,
|
|
288
|
+
path: relativePath(config.root, edge.from),
|
|
289
|
+
line: edge.line,
|
|
290
|
+
column: edge.column,
|
|
291
|
+
specifier: edge.specifier,
|
|
292
|
+
resolvedPath: relativePath(config.root, chain.at(-1)),
|
|
293
|
+
dependencyChain: chain.map(file => relativePath(config.root, file)),
|
|
294
|
+
message,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Enforce backend direction and keep executable apps as measurable composition roots. */
|
|
299
|
+
export function checkBackend(config, context) {
|
|
300
|
+
const violations = [];
|
|
301
|
+
const moduleRoots = roots(config.root, config.backend.modules);
|
|
302
|
+
const featureRoots = roots(config.root, config.backend.features);
|
|
303
|
+
const nonAppRoots = [...moduleRoots, ...featureRoots];
|
|
304
|
+
const sourceFiles = new Map(context.files.map(file => [path.resolve(file.fileName), file]));
|
|
305
|
+
for (const sourceFile of context.files) {
|
|
306
|
+
const fileName = path.resolve(sourceFile.fileName);
|
|
307
|
+
const fromModules = insideAny(moduleRoots, fileName);
|
|
308
|
+
const fromFeatures = insideAny(featureRoots, fileName);
|
|
309
|
+
const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
|
|
310
|
+
const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
|
|
311
|
+
if (app) {
|
|
312
|
+
const evidence = roleEvidence(context.ts, sourceFile);
|
|
313
|
+
if (evidence) {
|
|
314
|
+
violations.push({
|
|
315
|
+
ruleId: 'BE_APP_BUSINESS_ROLE',
|
|
316
|
+
path: relativePath(config.root, fileName),
|
|
317
|
+
...sourceLocation(sourceFile, evidence.node),
|
|
318
|
+
message: `Application composition source contains measurable business/provider role ${evidence.detail}. Move that role under src/features or src/modules.`,
|
|
319
|
+
});
|
|
320
|
+
} else if (slotAdmitsFile(config.hfs, relativePath(config.root, fileName)) !== true) {
|
|
321
|
+
violations.push({
|
|
322
|
+
ruleId: FORBIDDEN_APP_ROLE.test(path.posix.basename(app.relative)) ? 'BE_APP_BUSINESS_ROLE' : 'BE_APP_COMPOSITION_ONLY',
|
|
323
|
+
path: relativePath(config.root, fileName),
|
|
324
|
+
line: 1,
|
|
325
|
+
column: 1,
|
|
326
|
+
message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (!fromModules && !fromFeatures) continue;
|
|
331
|
+
if (fromApplication) {
|
|
332
|
+
for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) violations.push({
|
|
333
|
+
ruleId: 'BE_APPLICATION_TRANSPORT_FRAMEWORK',
|
|
334
|
+
path: relativePath(config.root, fileName),
|
|
335
|
+
...sourceLocation(sourceFile, evidence.node),
|
|
336
|
+
specifier: evidence.specifier,
|
|
337
|
+
message: `Feature application code imports transport framework surface ${evidence.detail}. Keep protocol decorators, request/response types, validation DTOs, and generated transport types under transport/.`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
for (const edge of context.edges.get(fileName) ?? []) {
|
|
341
|
+
if (fromApplication) {
|
|
342
|
+
const reexported = reexportedTransportEvidence(context.ts, context, sourceFiles, edge);
|
|
343
|
+
if (reexported) violations.push(finding(config, edge, 'BE_APPLICATION_TRANSPORT_FRAMEWORK', `Feature application code imports protocol surface ${reexported.evidence.detail} through an internal re-export. Keep protocol decorators and types under transport/.`, reexported.chain));
|
|
344
|
+
const transportChain = reachableViolation(context.edges, edge, target => insideFeatureLayer(featureRoots, target, 'transport'));
|
|
345
|
+
if (transportChain) violations.push(finding(config, edge, 'BE_APPLICATION_IMPORTS_TRANSPORT', 'Feature application code cannot depend on transport adapters or DTOs, including through a type import or barrel.', transportChain));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return violations;
|
|
350
|
+
}
|