@starci/hfs 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +110 -12
  3. package/bin/hfs.mjs +119 -15
  4. package/emit/compiler.mjs +35 -0
  5. package/emit/contracts.mjs +97 -0
  6. package/emit/operations-worker.mjs +24 -0
  7. package/emit/operations.mjs +126 -0
  8. package/emit/schema-worker.mjs +117 -0
  9. package/emit/static-graph.mjs +670 -0
  10. package/emit/type-schema.mjs +145 -0
  11. package/package.json +4 -1
  12. package/report/sonar.mjs +180 -0
  13. package/runtime/engine/admission.mjs +284 -0
  14. package/runtime/engine/digest.mjs +10 -0
  15. package/runtime/engine/ledger-db.mjs +1245 -0
  16. package/runtime/engine/machine-db.mjs +1484 -0
  17. package/runtime/engine/migrations/machine/0001-init.sql +887 -0
  18. package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
  19. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
  20. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
  21. package/runtime/engine/plain-object.mjs +5 -0
  22. package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
  23. package/runtime/knowledge/hfs/slots.yaml +405 -137
  24. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  25. package/runtime/knowledge/sonar-gate.yaml +85 -0
  26. package/runtime/modules/kernel/failure-codes.yaml +1480 -16
  27. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  28. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  29. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  30. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  31. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  32. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  33. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  34. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  35. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  36. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  37. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  38. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  39. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  40. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  41. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  42. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  43. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  44. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  45. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  46. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  47. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  48. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  49. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  50. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  51. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  52. package/runtime/scripts/checks/architecture/index.mjs +316 -0
  53. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  54. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  55. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  56. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  57. package/runtime/scripts/checks/architecture/owners.mjs +89 -0
  58. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  59. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  60. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  61. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  62. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  63. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  64. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  65. package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
  66. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  67. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  68. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  69. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  70. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  71. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  72. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  73. package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
  74. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  75. package/runtime/scripts/checks/architecture.mjs +41 -0
  76. package/runtime/scripts/checks/common.mjs +37 -0
  77. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  78. package/runtime/scripts/lib/artifact-hold.mjs +89 -0
  79. package/runtime/scripts/lib/artifact-store.mjs +103 -0
  80. package/runtime/scripts/lib/fs-kind.mjs +10 -0
  81. package/runtime/scripts/lib/git.mjs +53 -0
  82. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  83. package/runtime/scripts/lib/hfs-check.mjs +254 -28
  84. package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
  85. package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
  86. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  87. package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
  88. package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
  89. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
  90. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
  91. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  92. package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
  93. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  94. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
  95. package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +88 -40
  98. package/runtime/scripts/lib/hfs-tree.mjs +80 -0
  99. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  100. package/runtime/scripts/lib/json.mjs +22 -0
  101. package/runtime/scripts/lib/language.mjs +107 -0
  102. package/runtime/scripts/lib/path-key.mjs +2 -0
  103. package/runtime/scripts/lib/redact.mjs +148 -0
  104. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  105. package/runtime/scripts/lib/safe-remove.mjs +179 -0
  106. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  107. package/runtime/scripts/lib/sleep-sync.mjs +17 -0
  108. package/runtime/scripts/lib/stack-declaration.mjs +52 -0
  109. package/runtime/scripts/lib/stack-services.mjs +217 -0
  110. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  111. package/scaffold/service.mjs +333 -0
  112. package/sync/format.mjs +46 -0
  113. package/sync/hygiene.mjs +56 -24
  114. package/sync/index.mjs +126 -41
  115. package/sync/managed.mjs +170 -0
  116. package/sync/skeleton.mjs +32 -10
  117. package/sync/sonar-key.mjs +13 -0
  118. package/sync/ts-strict.mjs +48 -0
  119. package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
  120. package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
  121. package/templates/be/hooks/husky/pre-commit +13 -0
  122. package/templates/be/hooks/husky/pre-push +7 -0
  123. package/templates/be/package-scripts/package.json +21 -0
  124. package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
  125. package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
  126. package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
  127. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  128. package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
  129. package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
  130. package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
  131. package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
  132. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
  133. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
  134. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
  135. package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
  136. package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
  137. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
  138. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
  139. package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
  140. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
  141. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
  142. package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
  143. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
  144. package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
  145. package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
  146. package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
  147. package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
  148. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
  149. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
  150. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
  151. package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
  152. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
  153. package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
  154. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
  155. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
  156. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
  157. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
  158. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
  159. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
  162. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
  163. package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
  164. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
  165. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
  166. package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
  167. package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
  168. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
  169. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
  170. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
  171. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
  172. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
  173. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
  174. package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
  175. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
  176. package/templates/be/tool-config/eslint.config.mjs +3 -0
  177. package/templates/be/tool-config/jest.config.js +1 -0
  178. package/templates/be/tool-config/prettierignore +8 -0
  179. package/templates/be/tool-config/prettierrc +1 -0
  180. package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
  181. package/templates/be/tool-config/tsconfig.build.json +5 -0
  182. package/templates/be/tool-config/tsconfig.json +11 -0
  183. package/templates/common/gitignore.base +1 -1
  184. package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
  185. package/templates/fe/hooks/husky/pre-commit +16 -0
  186. package/templates/fe/hooks/husky/pre-push +6 -0
  187. package/templates/fe/package-scripts/package.json +17 -0
  188. package/templates/fe/parts/api-client.ts +44 -0
  189. package/templates/fe/parts/api-outcome.ts +7 -0
  190. package/templates/fe/quality-config/sonar-project.properties +8 -0
  191. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
  192. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
  193. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
  194. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  195. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
  196. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
  197. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
  198. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
  199. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
  200. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
  201. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
  202. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
  203. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
  204. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
  205. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
  206. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
  207. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
  208. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
  209. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
  210. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
  211. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
  212. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
  213. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
  214. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
  215. package/templates/fe/tool-config/eslint.config.mjs +3 -0
  216. package/templates/fe/tool-config/prettierignore +10 -0
  217. package/templates/fe/tool-config/prettierrc +1 -0
  218. package/templates/fe/tool-config/stylelint.config.mjs +3 -0
  219. package/templates/fe/tool-config/tsconfig.json +4 -0
  220. package/templates/be/pre-commit +0 -8
  221. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
  222. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
  223. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
  224. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
  225. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
  226. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
  227. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
  228. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
  229. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
  230. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
  231. package/templates/common/codecov.yml +0 -13
  232. package/templates/common/pre-push +0 -5
  233. package/templates/fe/e2e.yml +0 -22
  234. package/templates/fe/pre-commit +0 -7
  235. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
  236. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
  237. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
  238. package/templates/fe/sonar-project.properties +0 -11
  239. /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
  240. /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
  241. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
  242. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
  243. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
  244. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
  245. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
  246. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ import path from 'node:path';
2
+ import { machineKit } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R46 `background-unowned` (BE_BACKGROUND_UNOWNED, BE-CONVENTION 1.6 and 1.13). Background work is a transport that only
6
+ * a worker app runs:
7
+ *
8
+ * - every job (`*.job.ts`) and consumer (`*.consumer.ts`) is composed by an app of kind `worker`: the file is reachable,
9
+ * through runtime imports, from the root module of some worker app declared in hfs.json; one no worker reaches never runs;
10
+ * - no scheduler outside `platform/scheduling`: a `@Cron`, `@Interval` or `@Timeout` decorator of `@nestjs/schedule` and a
11
+ * `setInterval` call are refused everywhere else (platform/scheduling owns the lease, the fencing and the overlap guard);
12
+ * - a method whose name is one of the words the law names (`sweep`, `deliver`, `reconcile`, `retry`, alone or as the
13
+ * first word of a camelCase name) in a domain, integrations or feature class is reachable from a composed job or
14
+ * consumer: from the job or consumer files and everything they import, and from the files of the feature that holds them
15
+ * (its handlers are registered with the command bus, not imported by the job). Mechanisms of platform are exempt.
16
+ *
17
+ * Reachability is static (imports, re-exports and the feature of the job); it proves a job or consumer could call the method,
18
+ * not that it does.
19
+ */
20
+ export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
21
+
22
+ const RULE = 'BE_BACKGROUND_UNOWNED';
23
+ const BACKGROUND_ROLES = ['job', 'consumer'];
24
+ /** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
25
+ const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
26
+ const SCHEDULER_DECORATORS = ['Cron', 'Interval', 'Timeout'];
27
+ const SCHEDULER_PACKAGE = '@nestjs/schedule';
28
+ const SCHEDULING_CAPABILITY = 'scheduling';
29
+
30
+ const roleOf = rel => {
31
+ const parts = path.posix.basename(rel).split('.');
32
+ return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.includes(parts.at(-2)) ? parts.at(-2) : null;
33
+ };
34
+ const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
35
+
36
+ export function checkBackgroundUnowned(input) {
37
+ const { graph } = input;
38
+ const kit = machineKit(input);
39
+ const { ts, resolver } = kit;
40
+ const violations = [];
41
+ const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
42
+
43
+ const adjacency = new Map();
44
+ for (const edge of graph.edges) if (edge.runtime) {
45
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
46
+ adjacency.get(edge.from).push(edge.to);
47
+ }
48
+ const closure = starts => {
49
+ const seen = new Set(starts);
50
+ const queue = [...starts];
51
+ while (queue.length) for (const next of adjacency.get(queue.shift()) ?? []) if (!seen.has(next)) { seen.add(next); queue.push(next); }
52
+ return seen;
53
+ };
54
+
55
+ const workerRoots = resolver.repo.apps.filter(app => app.kind === 'worker').map(app => `apps/${app.name}/src/app.module.ts`).filter(rel => graph.files.has(rel));
56
+ const composed = closure(workerRoots);
57
+ const background = [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
58
+ const running = background.filter(file => composed.has(file.rel));
59
+ for (const file of background) {
60
+ if (composed.has(file.rel)) continue;
61
+ const role = roleOf(file.rel);
62
+ report(file, file.sourceFile, `${file.rel} is a ${role} that no worker app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'job' ? 'schedule' : 'message'} module of its feature is imported by the root module of an app of kind worker.`, { role });
63
+ }
64
+
65
+ // Everything a composed job or consumer can reach: its imports, and the files of its own feature (command handlers).
66
+ const roots = new Set();
67
+ for (const file of running) {
68
+ roots.add(file.rel);
69
+ const owner = file.owner?.root;
70
+ if (owner) for (const other of graph.files.values()) if (other.owner?.root === owner) roots.add(other.rel);
71
+ }
72
+ const reachable = closure([...roots]);
73
+
74
+ let methods = 0;
75
+ for (const file of graph.files.values()) {
76
+ const checker = kit.checkerOf(file.sourceFile);
77
+ const inScheduling = file.owner?.slot === 'be.platform' && path.posix.basename(file.owner.root) === SCHEDULING_CAPABILITY;
78
+ const mechanism = file.tier === 'platform';
79
+ kit.walk(file.sourceFile, node => {
80
+ if (!inScheduling && ts.isDecorator(node)) {
81
+ const call = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
82
+ const binding = kit.importBinding(checker, call);
83
+ if (binding && binding.module === SCHEDULER_PACKAGE && SCHEDULER_DECORATORS.includes(binding.name)) {
84
+ report(file, node, `@${binding.name} schedules work inside a class outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts that dispatches one command or query, and let platform/scheduling and a worker app run it.`, { scheduler: binding.name });
85
+ }
86
+ }
87
+ if (!inScheduling && ts.isCallExpression(node)) {
88
+ const callee = node.expression;
89
+ const bare = ts.isIdentifier(callee) && callee.text === 'setInterval' && kit.declarationsOf(checker, callee).every(declaration => declaration.getSourceFile().isDeclarationFile);
90
+ const member = ts.isPropertyAccessExpression(callee) && callee.name.text === 'setInterval' && ts.isIdentifier(callee.expression) && ['globalThis', 'window', 'global'].includes(callee.expression.text);
91
+ const global = bare || member;
92
+ if (global) report(file, node, 'setInterval starts a scheduler outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts and let platform/scheduling and a worker app run it.', { scheduler: 'setInterval' });
93
+ }
94
+ if (!mechanism && ts.isClassDeclaration(node)) {
95
+ for (const member of node.members) {
96
+ if (!ts.isMethodDeclaration(member) || !member.name) continue;
97
+ const name = kit.propertyNameText(member.name);
98
+ if (!name || !isBackgroundName(name)) continue;
99
+ methods += 1;
100
+ if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no job or consumer composed by a worker app can reach. Add a job in transport/schedule or a consumer in transport/message of a feature that runs it, and compose that transport module in a worker app.`, { method: name });
101
+ }
102
+ }
103
+ return true;
104
+ });
105
+ }
106
+ return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
107
+ }
@@ -0,0 +1,94 @@
1
+ import { builtinModules } from 'node:module';
2
+ import { sourceLocation } from './typescript.mjs';
3
+
4
+ /**
5
+ * R55 `client-reaches-server` (FE_CLIENT_REACHES_SERVER), the repository half of the eslint rule `client-no-server-import`,
6
+ * which sees one hop from a file that carries the directive. The machine walks the resolved import graph from every module
7
+ * whose first statement is the `"use client"` directive, over runtime edges only (a type-only import vanishes at build), and
8
+ * reports any reachable module (the entry itself included) that imports a server-only surface:
9
+ *
10
+ * - `server-only`, `next/headers`, `next/server`, `next-intl/server`;
11
+ * - a Node built-in: `node:*` and every bare name of `module.builtinModules` (`fs`, `path`, `crypto`, ...).
12
+ *
13
+ * The finding sits on the client entry, names the shortest import chain from it to the offending module (multi-hop) and the
14
+ * specifier. Only imports that reach the client bundle count: `import type`, an import whose every named specifier is
15
+ * `type`, and `export type` re-exports are skipped.
16
+ */
17
+ export const CLIENT_REACHES_SERVER_RULE_IDS = ['FE_CLIENT_REACHES_SERVER'];
18
+
19
+ const RULE = 'FE_CLIENT_REACHES_SERVER';
20
+ const SERVER_MODULES = new Set(['server-only', 'next/headers', 'next/server', 'next-intl/server']);
21
+ const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u, '').split('/')[0]));
22
+
23
+ const isServerSpecifier = specifier => SERVER_MODULES.has(specifier) || specifier.startsWith('node:') || NODE_BUILTINS.has(specifier.split('/')[0]);
24
+
25
+ export function checkClientReachesServer({ graph, context }) {
26
+ const ts = context.ts;
27
+ const isClientEntry = file => {
28
+ const first = file.sourceFile.statements[0];
29
+ return Boolean(first) && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === 'use client';
30
+ };
31
+ /** True when the import or re-export declaration reaches the bundle: not `import type`, not all-`type` specifiers. */
32
+ const reachesBundle = declaration => {
33
+ if (ts.isImportDeclaration(declaration)) {
34
+ const clause = declaration.importClause;
35
+ if (!clause) return true;
36
+ if (clause.isTypeOnly) return false;
37
+ if (!clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
38
+ const elements = clause.namedBindings.elements;
39
+ return elements.length === 0 || !elements.every(element => element.isTypeOnly);
40
+ }
41
+ return true;
42
+ }
43
+ return !declaration.isTypeOnly;
44
+ };
45
+ /** The server-only specifiers a file imports, with the node to point at. */
46
+ const serverImports = file => {
47
+ const found = [];
48
+ const visit = node => {
49
+ const specifier = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && reachesBundle(node) ? node.moduleSpecifier
50
+ : ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require')) ? node.arguments[0]
51
+ : null;
52
+ if (specifier && ts.isStringLiteralLike(specifier) && isServerSpecifier(specifier.text)) found.push({ specifier: specifier.text, node });
53
+ ts.forEachChild(node, visit);
54
+ };
55
+ visit(file.sourceFile);
56
+ return found;
57
+ };
58
+
59
+ const runtime = new Map();
60
+ for (const edge of graph.edges) {
61
+ if (!edge.runtime) continue;
62
+ if (!runtime.has(edge.from)) runtime.set(edge.from, []);
63
+ runtime.get(edge.from).push(edge);
64
+ }
65
+ const serverByFile = new Map();
66
+ const serverOf = rel => {
67
+ if (!serverByFile.has(rel)) serverByFile.set(rel, serverImports(graph.files.get(rel)));
68
+ return serverByFile.get(rel);
69
+ };
70
+
71
+ const violations = [];
72
+ const entries = [...graph.files.values()].filter(isClientEntry).sort((a, b) => a.rel.localeCompare(b.rel));
73
+ for (const entry of entries) {
74
+ const seen = new Map([[entry.rel, { chain: [entry.rel], first: null }]]);
75
+ const queue = [entry.rel];
76
+ for (let head = 0; head < queue.length; head += 1) {
77
+ const rel = queue[head];
78
+ const state = seen.get(rel);
79
+ for (const hit of serverOf(rel)) {
80
+ const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
81
+ violations.push({
82
+ ruleId: RULE, path: entry.rel, line: at.line, column: at.column, clientEntry: entry.rel, importer: rel, specifier: hit.specifier, dependencyChain: state.chain,
83
+ message: `${entry.rel} is a client module ("use client") and reaches ${hit.specifier} through ${[...state.chain, hit.specifier].join(' -> ')}; a server-only surface never enters the client bundle. Read on the server and pass the value down, or move the import out of the client graph.`,
84
+ });
85
+ }
86
+ for (const edge of runtime.get(rel) ?? []) {
87
+ if (seen.has(edge.to)) continue;
88
+ seen.set(edge.to, { chain: [...state.chain, edge.to], first: state.first ?? { line: edge.line, column: edge.column } });
89
+ queue.push(edge.to);
90
+ }
91
+ }
92
+ }
93
+ return { violations, coverage: { status: 'checked', clientEntries: entries.length } };
94
+ }