@starci/hfs 4.0.10 → 4.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.
Files changed (271) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +3 -2
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +187 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +221 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/removed-vocabulary.mjs +28 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/runtime-root.mjs +17 -0
  16. package/runtime/engine/secrets.mjs +35 -31
  17. package/runtime/engine/sonar-config.mjs +21 -0
  18. package/runtime/engine/temp-root.mjs +32 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  20. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  21. package/runtime/knowledge/hfs/rules.yaml +98 -65
  22. package/runtime/knowledge/hfs/slots.yaml +11 -14
  23. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  24. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  25. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  26. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  27. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  28. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  29. package/runtime/modules/models/registry.yaml +25 -359
  30. package/runtime/modules/models/runtimes.yaml +92 -256
  31. package/runtime/modules/models/tiers.yaml +65 -0
  32. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  33. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  34. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  35. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  36. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  37. package/runtime/scripts/api/git/lib.mjs +34 -1
  38. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  39. package/runtime/scripts/api/process/run-program.mjs +2 -1
  40. package/runtime/scripts/api/sops/lib.mjs +198 -120
  41. package/runtime/scripts/hfs/allows.mjs +3 -3
  42. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  43. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  44. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  45. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  47. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  48. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  49. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  50. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  51. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  52. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  53. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  54. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  55. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  56. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  57. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  58. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  59. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  60. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  61. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  62. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  63. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  65. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  66. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  67. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  69. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  70. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  71. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  72. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  74. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  75. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  76. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  77. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  78. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  79. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  80. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  81. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  82. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  83. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  84. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  85. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  86. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  87. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  88. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  89. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  90. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  91. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  92. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  93. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  94. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  95. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  96. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  97. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  98. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  99. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  100. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  101. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  102. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  103. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  104. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  105. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  106. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  107. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  108. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  109. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  110. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  111. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  112. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  113. package/runtime/scripts/hfs/architecture.mjs +3 -4
  114. package/runtime/scripts/hfs/check.mjs +61 -70
  115. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  116. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  117. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  118. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  119. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  120. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  121. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  122. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  123. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  124. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  125. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  126. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  127. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  128. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  129. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  130. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  131. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  132. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  133. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  134. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  135. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  136. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  137. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  138. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  139. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  140. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  141. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  142. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  143. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  144. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  145. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  146. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  147. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  148. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  149. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  150. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  151. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  152. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  153. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  154. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  155. package/runtime/scripts/hfs/secret.mjs +59 -48
  156. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  157. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  158. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  159. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  160. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  161. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  162. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  163. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  165. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  166. package/runtime/scripts/hfs/slots.mjs +72 -693
  167. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  168. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  169. package/runtime/scripts/hfs/tree.mjs +19 -14
  170. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  171. package/runtime/scripts/hfs/view.mjs +1 -1
  172. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  173. package/runtime/scripts/lib/event-contract.mjs +17 -13
  174. package/runtime/scripts/lib/git.mjs +2 -2
  175. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  176. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  177. package/runtime/scripts/lib/i18n.mjs +3 -2
  178. package/runtime/scripts/lib/in-order.mjs +71 -0
  179. package/runtime/scripts/lib/language.mjs +3 -3
  180. package/runtime/scripts/lib/list.mjs +8 -1
  181. package/runtime/scripts/lib/path-key.mjs +38 -5
  182. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  183. package/runtime/scripts/lib/regex.mjs +1 -1
  184. package/runtime/scripts/lib/same-text.mjs +1 -1
  185. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  186. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  187. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  188. package/runtime/scripts/lib/stack-services.mjs +1 -2
  189. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  190. package/runtime/scripts/lib/walk.mjs +6 -1
  191. package/scaffold/add-cli-lite.mjs +2 -1
  192. package/scaffold/add-table.mjs +2 -2
  193. package/scaffold/add.mjs +3 -2
  194. package/scaffold/app.mjs +4 -2
  195. package/scaffold/edition-gate.mjs +25 -25
  196. package/scaffold/lite-exports.mjs +2 -1
  197. package/scaffold/service.mjs +5 -4
  198. package/sync/hygiene.mjs +15 -9
  199. package/sync/index.mjs +7 -3
  200. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  201. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  202. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  203. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  204. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  205. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  206. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  207. package/templates/app/starciwork.gitignore +1 -1
  208. package/templates/be/image/api/Dockerfile +1 -1
  209. package/templates/be/image/cli/Dockerfile +1 -1
  210. package/templates/be/image/worker/Dockerfile +1 -1
  211. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  212. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  213. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  214. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  215. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  216. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  217. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  218. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  219. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  220. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  221. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  222. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  223. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  224. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  225. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  226. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  227. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  228. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  229. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  230. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  231. package/templates/fe/image/next/Dockerfile +2 -1
  232. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  233. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  234. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  235. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  236. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  238. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  239. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  241. package/upgrade/index.mjs +9 -2
  242. package/runtime/engine/admission.mjs +0 -308
  243. package/runtime/engine/canonical-json.mjs +0 -10
  244. package/runtime/engine/db/blob.mjs +0 -315
  245. package/runtime/engine/db/ledger-paths.mjs +0 -83
  246. package/runtime/engine/db/ledger.mjs +0 -1118
  247. package/runtime/engine/db/machine-connection.mjs +0 -135
  248. package/runtime/engine/db/machine-schema.mjs +0 -84
  249. package/runtime/engine/db/machine.mjs +0 -1367
  250. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  251. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  252. package/runtime/engine/db/provider-reservations.mjs +0 -101
  253. package/runtime/engine/digest.mjs +0 -16
  254. package/runtime/engine/refuse.mjs +0 -11
  255. package/runtime/modules/ops/_labels.yaml +0 -53
  256. package/runtime/scripts/api/node/lib.mjs +0 -14
  257. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  258. package/runtime/scripts/api/process/lib.mjs +0 -111
  259. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  260. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  261. package/runtime/scripts/connectors/lib.mjs +0 -488
  262. package/runtime/scripts/lib/clip.mjs +0 -19
  263. package/runtime/scripts/lib/display-names.mjs +0 -259
  264. package/runtime/scripts/lib/example-refs.mjs +0 -158
  265. package/runtime/scripts/lib/json-schema.mjs +0 -52
  266. package/runtime/scripts/lib/process-identity.mjs +0 -6
  267. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  268. package/runtime/scripts/lib/redact.mjs +0 -161
  269. package/runtime/scripts/lib/sleep.mjs +0 -7
  270. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  271. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -1,18 +1,14 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { canonical, isInside } from './config.mjs';
4
- import { readJsonFile as readJson } from '../../lib/json.mjs';
2
+ import { isInside } from './config.mjs';
5
3
  import { frameworkPinnedRootFiles } from './framework-pinned.mjs';
6
- import { exportTargetStrings, isUnshadowedCommonJsRequire, reachableViolation, relativePath, unwrapExpression } from './typescript.mjs';
7
- import { normalizedSymbolValue, returnedExpressions as sharedReturnedExpressions } from './ast-walks.mjs';
4
+ import { reachableViolation, relativePath } from './typescript.mjs';
5
+ import { normalizedSymbolValue } from './ast-walks.mjs';
8
6
  import { sourceLocation } from '../../lib/ts-ast.mjs';
9
-
7
+ import { checkWorldRenderBoundaries } from './frontend-world.mjs';
8
+ import { checkGrammar } from './frontend-grammar.mjs';
9
+ import { checkRoute } from './frontend-routing.mjs';
10
10
  const FEATURE_TIERS = new Set(['pages', 'layouts', 'overlays']);
11
11
  const COMPONENT_TIERS = new Set(['blocks', 'composites', 'branches', 'leaves']);
12
- const LOWER_COMPONENT_TIERS = new Set(['composites', 'branches', 'leaves']);
13
- const WORLD_NAVIGATION_CALLS = new Set(['useParams', 'usePathname', 'useRouter', 'useSearchParams', 'useSelectedLayoutSegment', 'useSelectedLayoutSegments']);
14
- const WORLD_INTL_CALLS = new Set(['useLocale', 'useMessages', 'useNow', 'useTimeZone', 'useTranslations']);
15
- const WORLD_SWR_CALLS = new Set(['default', 'useSWR', 'useSWRConfig', 'useSWRImmutable', 'useSWRMutation']);
16
12
 
17
13
  function absolute(root, relative) {
18
14
  return path.resolve(root, ...relative.split('/'));
@@ -54,279 +50,8 @@ function violation(config, sourceFile, node, ruleId, message, extra = {}) {
54
50
  };
55
51
  }
56
52
 
57
- function grammarExport(manifest, packageName, specifier) {
58
- const key = specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`;
59
- const value = manifest?.exports?.[key];
60
- const targets = exportTargetStrings(value);
61
- return targets.length > 0 && targets.every(target => target.startsWith('./') && !target.includes('\\') && !target.split('/').includes('..'));
62
- }
63
-
64
- function grammarExportTargets(manifest, packageRoot, packageName, specifier) {
65
- const key = specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`;
66
- return exportTargetStrings(manifest?.exports?.[key]).filter(target => target.startsWith('./') && !target.includes('\\') && !target.split('/').includes('..'))
67
- .map(target => canonical(path.resolve(packageRoot, target)));
68
- }
69
-
70
- function literalModules(ts, sourceFile, checker) {
71
- const modules = [];
72
- const visit = node => {
73
- if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
74
- modules.push({ node: node.moduleSpecifier, specifier: node.moduleSpecifier.text });
75
- } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)
76
- && node.moduleReference.expression && ts.isStringLiteralLike(node.moduleReference.expression)) {
77
- modules.push({ node: node.moduleReference.expression, specifier: node.moduleReference.expression.text });
78
- } else if (ts.isCallExpression(node) && node.arguments.length > 0 && ts.isStringLiteralLike(node.arguments[0])
79
- && (node.expression.kind === ts.SyntaxKind.ImportKeyword || isUnshadowedCommonJsRequire(ts, checker, node.expression))) {
80
- modules.push({ node: node.arguments[0], specifier: node.arguments[0].text });
81
- }
82
- ts.forEachChild(node, visit);
83
- };
84
- visit(sourceFile);
85
- return modules;
86
- }
87
-
88
- function cssImports(content) {
89
- const imports = [];
90
- const withoutComments = content.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\r\n]/g, ' '));
91
- const pattern = /@import\s+(?:url\(\s*(?:(["'])(.*?)\1|([^\s)]+))\s*\)|(["'])(.*?)\4)/gi;
92
- for (const match of withoutComments.matchAll(pattern)) {
93
- imports.push({ specifier: match[2] ?? match[3] ?? match[5], index: match.index });
94
- }
95
- return imports;
96
- }
97
-
98
- function checkGrammar(config, context) {
99
- const grammar = config.frontend.grammar;
100
- if (!grammar) return [];
101
- const violations = [];
102
- const local = context.workspaces.find(workspace => workspace.name === grammar.package);
103
- // Node resolution per consumer: in an npm-workspaces monorepo a consumer whose range differs from the
104
- // hoisted copy gets its own apps/<app>/node_modules/<package> (one consumer: apps/app on 0.5.0 beside a hoisted
105
- // 0.4.11). Every consumer is judged against the copy it actually resolves, never the hoisted one alone.
106
- // An app installs once, at its root (config.packageRoot): a side folder has no node_modules of its own.
107
- const installRoot = config.packageRoot ?? config.root;
108
- const installedFrom = (from) => {
109
- for (let dir = from; isInside(installRoot, dir); dir = path.dirname(dir)) {
110
- const candidate = path.join(dir, 'node_modules', ...grammar.package.split('/'));
111
- if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
112
- if (dir === installRoot || path.dirname(dir) === dir) break;
113
- }
114
- return path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
115
- };
116
- const consumers = grammar.consumerManifests.map(relative => {
117
- const root = path.dirname(path.join(config.root, ...relative.split('/')));
118
- const packageRoot = local?.root ?? installedFrom(root);
119
- return { relative, root, packageRoot, packageManifest: readJson(path.join(packageRoot, 'package.json')),
120
- manifest: readJson(path.join(config.root, ...relative.split('/'))) ?? {} };
121
- });
122
- const installs = [...new Map(consumers.map(consumer => [consumer.packageRoot, consumer])).values()];
123
- const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
124
- const manifestFile = path.join(packageRoot, 'package.json');
125
- const manifest = readJson(manifestFile);
126
- const ownerOf = (fileName) => consumers.filter(consumer => isInside(consumer.root, fileName))
127
- .sort((a, b) => b.root.length - a.root.length)[0] ?? null;
128
- const contractProblems = [];
129
- const styleBypasses = [];
130
- for (const install of installs) {
131
- const where = installs.length > 1 ? ` (as ${install.relative} resolves it, ${relativePath(config.root, install.packageRoot)})` : '';
132
- if (install.packageManifest?.name !== grammar.package) contractProblems.push(`installed package ${grammar.package} is unavailable or has a different name${where}`);
133
- if (!grammarExport(install.packageManifest, grammar.package, grammar.entry)) contractProblems.push(`${grammar.entry} is not a safe declared package export${where}`);
134
- if (!grammarExport(install.packageManifest, grammar.package, grammar.styleEntry)) contractProblems.push(`${grammar.styleEntry} is not a safe declared style export${where}`);
135
- const actualPeers = Object.keys(install.packageManifest?.peerDependencies ?? {}).sort();
136
- const selectedPeers = [...grammar.peers].sort();
137
- if (JSON.stringify(actualPeers) !== JSON.stringify(selectedPeers)) {
138
- contractProblems.push(`${grammar.package} peerDependencies must exactly match the selected peers ${selectedPeers.join(', ')}${where}`);
139
- }
140
- }
141
- for (const consumer of consumers) if (!Object.hasOwn(consumer.manifest.dependencies ?? {}, grammar.package)) {
142
- contractProblems.push(`${consumer.relative} does not declare ${grammar.package} as a runtime dependency`);
143
- }
144
- for (const peer of grammar.peers) {
145
- for (const consumer of consumers) if (!Object.hasOwn(consumer.manifest.dependencies ?? {}, peer) && !Object.hasOwn(consumer.manifest.peerDependencies ?? {}, peer)) {
146
- contractProblems.push(`${consumer.relative} does not declare Grammar peer ${peer}`);
147
- }
148
- }
149
- for (const source of grammar.styleSources) {
150
- const content = fs.readFileSync(path.join(config.root, ...source.split('/')), 'utf8');
151
- const grammarStyles = cssImports(content).filter(item => item.specifier === grammar.package || item.specifier.startsWith(`${grammar.package}/`));
152
- if (!grammarStyles.some(item => item.specifier === grammar.styleEntry)) contractProblems.push(`${source} does not import ${grammar.styleEntry}`);
153
- for (const imported of grammarStyles) if (imported.specifier !== grammar.styleEntry) {
154
- contractProblems.push(`${source} imports Grammar style ${imported.specifier} outside the selected style entry`);
155
- styleBypasses.push({ ruleId: 'ARCH_GRAMMAR_EXPORT_BYPASS', path: source,
156
- line: content.slice(0, imported.index).split(/\r?\n/).length, column: 1, package: grammar.package, specifier: imported.specifier,
157
- message: `Style source must use the selected Grammar style entry ${grammar.styleEntry}; ${imported.specifier} is outside that contract.` });
158
- }
159
- }
160
- if (contractProblems.length) violations.push({
161
- ruleId: 'ARCH_GRAMMAR_CONTRACT_INVALID',
162
- path: fs.existsSync(manifestFile) && isInside(config.root, manifestFile) ? relativePath(config.root, manifestFile) : 'package.json',
163
- line: 1,
164
- column: 1,
165
- package: grammar.package,
166
- message: `Grammar contract is invalid: ${contractProblems.join('; ')}.`,
167
- });
168
- const allowed = new Set([grammar.entry, grammar.styleEntry]);
169
- violations.push(...styleBypasses);
170
- for (const sourceFile of context.files) {
171
- if (installs.some(install => isInside(install.packageRoot, sourceFile.fileName))) continue;
172
- const owner = ownerOf(sourceFile.fileName);
173
- if (!owner) continue;
174
- const edgesByStart = new Map((context.edges.get(path.resolve(sourceFile.fileName)) ?? []).map(edge => [edge.node.getStart(sourceFile), edge]));
175
- for (const reference of literalModules(context.ts, sourceFile, context.checkerFor(sourceFile.fileName))) {
176
- if (reference.specifier !== grammar.package && !reference.specifier.startsWith(`${grammar.package}/`)) continue;
177
- if (allowed.has(reference.specifier)) {
178
- const edge = edgesByStart.get(reference.node.getStart(sourceFile));
179
- const expected = grammarExportTargets(owner.packageManifest, owner.packageRoot, grammar.package, reference.specifier);
180
- if (!edge || expected.includes(canonical(edge.to))) continue;
181
- }
182
- violations.push(violation(config, sourceFile, reference.node, 'ARCH_GRAMMAR_EXPORT_BYPASS',
183
- `Product source must import the selected Grammar code entry ${grammar.entry} or style entry ${grammar.styleEntry}; ${reference.specifier} is outside that contract.`,
184
- { specifier: reference.specifier, package: grammar.package }));
185
- }
186
- }
187
- return violations;
188
- }
189
-
190
- function hasClientDirective(ts, sourceFile) {
191
- return sourceFile.statements.find(statement => ts.isExpressionStatement(statement)
192
- && ts.isStringLiteral(statement.expression) && statement.expression.text === 'use client');
193
- }
194
-
195
- function importsForSource(context, fileName) {
196
- return context.edges.get(path.resolve(fileName)) ?? [];
197
- }
198
-
199
- function importBindings(ts, sourceFile, edges) {
200
- const byStart = new Map(edges.map(edge => [edge.node.getStart(sourceFile), edge]));
201
- const bindings = new Map();
202
- for (const statement of sourceFile.statements) {
203
- if (!ts.isImportDeclaration(statement) || !statement.importClause || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
204
- const edge = byStart.get(statement.moduleSpecifier.getStart(sourceFile));
205
- if (!edge) continue;
206
- const clause = statement.importClause;
207
- if (clause.name && !clause.isTypeOnly) bindings.set(clause.name.text, edge);
208
- if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) bindings.set(clause.namedBindings.name.text, edge);
209
- if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
210
- for (const element of clause.namedBindings.elements) if (!element.isTypeOnly && !clause.isTypeOnly) bindings.set(element.name.text, edge);
211
- }
212
- }
213
- return bindings;
214
- }
215
-
216
- function importedNames(ts, sourceFile, moduleName) {
217
- const names = new Map();
218
- for (const statement of sourceFile.statements) {
219
- if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== moduleName || !statement.importClause || statement.importClause.isTypeOnly) continue;
220
- const named = statement.importClause.namedBindings;
221
- if (!named || !ts.isNamedImports(named)) continue;
222
- for (const element of named.elements) if (!element.isTypeOnly) names.set(element.name.text, element.propertyName?.text ?? element.name.text);
223
- }
224
- return names;
225
- }
226
-
227
- function jsxRootName(ts, tagName) {
228
- if (ts.isIdentifier(tagName)) return tagName.text;
229
- if (ts.isPropertyAccessExpression(tagName)) {
230
- let current = tagName;
231
- while (ts.isPropertyAccessExpression(current)) current = current.expression;
232
- return ts.isIdentifier(current) ? current.text : null;
233
- }
234
- return null;
235
- }
236
-
237
- function routeFunction(ts, sourceFile) {
238
- const variables = new Map();
239
- const functions = new Map();
240
- let directDefault = null;
241
- let exportNode = null;
242
- for (const statement of sourceFile.statements) {
243
- if (ts.isVariableStatement(statement)) {
244
- for (const declaration of statement.declarationList.declarations) {
245
- if (ts.isIdentifier(declaration.name) && declaration.initializer
246
- && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) variables.set(declaration.name.text, declaration.initializer);
247
- }
248
- }
249
- if (ts.isFunctionDeclaration(statement) && statement.name) functions.set(statement.name.text, statement);
250
- if (ts.isFunctionDeclaration(statement) && statement.modifiers?.some(item => item.kind === ts.SyntaxKind.DefaultKeyword)) directDefault = statement;
251
- if (ts.isExportAssignment(statement) && !statement.isExportEquals) exportNode = statement;
252
- }
253
- if (directDefault) return { fn: directDefault, exportNode: directDefault };
254
- if (!exportNode || !ts.isIdentifier(exportNode.expression)) return { fn: null, exportNode };
255
- return { fn: variables.get(exportNode.expression.text) ?? functions.get(exportNode.expression.text) ?? null, exportNode };
256
- }
257
-
258
- function routePageRoots(context, sourceFile, featureRoots) {
259
- const workspace = context.workspaceOf(sourceFile.fileName);
260
- if (!workspace) return featureRoots.map(root => path.join(root, 'pages'));
261
- const local = featureRoots.filter(root => context.workspaceOf(root) === workspace);
262
- return (local.length ? local : featureRoots).map(root => path.join(root, 'pages'));
263
- }
264
-
265
- function routingTermination(ts, statement, navigation) {
266
- if (ts.isThrowStatement(statement)) return true;
267
- if (ts.isBlock(statement)) return statement.statements.length > 0 && routingTermination(ts, statement.statements.at(-1), navigation);
268
- const expression = ts.isExpressionStatement(statement) ? statement.expression : (ts.isReturnStatement(statement) ? statement.expression : null);
269
- return Boolean(expression && ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)
270
- && ['redirect', 'notFound'].includes(navigation.get(expression.expression.text)));
271
- }
272
-
273
- function routingGuard(ts, node, navigation) {
274
- return ts.isIfStatement(node) && !node.elseStatement && routingTermination(ts, node.thenStatement, navigation);
275
- }
276
-
277
- function checkRoute(config, context, sourceFile, roots) {
278
- const { ts } = context;
279
- const violations = [];
280
- const edges = importsForSource(context, sourceFile.fileName);
281
- const bindings = importBindings(ts, sourceFile, edges);
282
- const route = routeFunction(ts, sourceFile);
283
- if (!route.exportNode || !route.fn) {
284
- violations.push({ ruleId: 'FE_ROUTE_DEFAULT_EXPORT', path: relativePath(config.root, sourceFile.fileName), line: 1, column: 1,
285
- message: 'A page.tsx must default-export one local route function.' });
286
- return violations;
287
- }
288
- const jsx = [];
289
- const navigation = importedNames(ts, sourceFile, 'next/navigation');
290
- const adapterCalls = [];
291
- const decisions = [];
292
- const visit = (node, insideNavigationArgument = false) => {
293
- if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) jsx.push(node);
294
- if (ts.isIfStatement(node) || ts.isSwitchStatement(node) || (ts.isConditionalExpression(node) && !insideNavigationArgument)) decisions.push(node);
295
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ['redirect', 'notFound'].includes(navigation.get(node.expression.text))) {
296
- adapterCalls.push(node);
297
- for (const argument of node.arguments) visit(argument, true);
298
- return;
299
- }
300
- ts.forEachChild(node, child => visit(child, insideNavigationArgument));
301
- };
302
- visit(route.fn);
303
- const pages = routePageRoots(context, sourceFile, roots.features);
304
- const mountedPages = jsx.filter(node => {
305
- const opening = ts.isJsxElement(node) ? node.openingElement : node;
306
- const local = jsxRootName(ts, opening.tagName);
307
- const edge = local ? bindings.get(local) : null;
308
- return edge && Boolean(reachableViolation(context.edges, edge, target => insideAny(pages, target), { follow: candidate => candidate.reexport && candidate.runtime }));
309
- });
310
- const navigationOnly = jsx.length === 0 && adapterCalls.length === 1;
311
- if (!navigationOnly && (mountedPages.length !== 1 || jsx.length !== 1)) {
312
- violations.push(violation(config, sourceFile, route.fn, 'FE_ROUTE_ONE_PAGE', 'A page.tsx route must mount exactly one pages-tier component, or be a zero-JSX redirect/notFound adapter.', { ts }));
313
- }
314
- if (!navigationOnly) for (const decision of decisions) if (!routingGuard(ts, decision, navigation)) {
315
- violations.push(violation(config, sourceFile, decision, 'FE_ROUTE_DRAWING_DECISION', 'Visual route files may use terminal redirect/notFound guards but cannot select or omit visual composition.', { ts }));
316
- }
317
- const clientDirective = hasClientDirective(ts, sourceFile);
318
- if (clientDirective) violations.push(violation(config, sourceFile, clientDirective, 'FE_ROUTE_CLIENT_BOUNDARY', 'A page.tsx route adapter cannot own the client boundary.', { ts }));
319
- const forbiddenRouteHooks = new Set(['useRouter', 'usePathname', 'useSearchParams', 'useParams', 'useSelectedLayoutSegment', 'useSelectedLayoutSegments']);
320
- for (const [local, imported] of navigation) if (forbiddenRouteHooks.has(imported)) {
321
- const identifier = sourceFile.statements.flatMap(statement => ts.isImportDeclaration(statement) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)
322
- ? statement.importClause.namedBindings.elements.filter(item => item.name.text === local).map(item => item.name) : [])[0];
323
- if (identifier) violations.push(violation(config, sourceFile, identifier, 'FE_ROUTE_CLIENT_HOOK', `Server route adapter imports ${imported}.`, { ts }));
324
- }
325
- return violations;
326
- }
327
-
328
53
  function roleEntry(relative) {
329
- const parts = String(relative).replace(/\\/g, '/').split('/');
54
+ const parts = String(relative).replaceAll('\\', '/').split('/');
330
55
  return parts.length === 1 && /^index\.[cm]?[jt]sx?$/i.test(parts[0]);
331
56
  }
332
57
 
@@ -364,11 +89,9 @@ function checkFrontendSourceLayout(config, sourceFile, roots) {
364
89
  if (located.role === 'components') valid = COMPONENT_TIERS.has(first) || roleEntry(located.relative);
365
90
  if (located.role === 'hooks') valid = located.relative.includes('/') || roleEntry(located.relative);
366
91
  return valid ? [] : [{ ruleId: 'FE_SOURCE_LAYOUT_INVALID', path: relativePath(config.root, sourceFile.fileName), line: 1, column: 1,
367
- message: located.role === 'features'
368
- ? 'Frontend features contain only pages, layouts and overlays plus an optional root public entry.'
369
- : located.role === 'components'
370
- ? 'Frontend components contain only blocks, composites, branches and leaves plus an optional root public entry.'
371
- : 'Every authored custom hook is grouped below a domain folder under hooks; only the root public entry may sit directly under hooks.' }];
92
+ message: ({ features: 'Frontend features contain only pages, layouts and overlays plus an optional root public entry.',
93
+ components: 'Frontend components contain only blocks, composites, branches and leaves plus an optional root public entry.',
94
+ hooks: 'Every authored custom hook is grouped below a domain folder under hooks; only the root public entry may sit directly under hooks.' })[located.role] }];
372
95
  }
373
96
 
374
97
  function declarationName(ts, declaration) {
@@ -379,572 +102,131 @@ function declarationName(ts, declaration) {
379
102
  return null;
380
103
  }
381
104
 
382
- function checkCustomHookLocations(config, context, sourceFile, roots) {
383
- if (insideAny(roots.hooks, sourceFile.fileName)) return [];
384
- const { ts } = context;
385
- const checker = context.checkerFor(sourceFile.fileName);
386
- const violations = [];
387
- const seen = new Set();
388
- const report = (symbol, node) => {
389
- const identity = normalizedSymbolValue(ts, checker, symbol) ?? symbol;
390
- if (!identity || seen.has(identity) || checker.getTypeOfSymbolAtLocation(identity, node).getCallSignatures().length === 0) return;
391
- seen.add(identity);
392
- violations.push(violation(config, sourceFile, node, 'FE_CUSTOM_HOOK_LOCATION',
393
- 'Every authored custom useX hook declaration belongs under the configured hooks root; calling built-in React hooks inside a visual is still valid.', { ts }));
394
- };
395
- const inspect = declaration => {
396
- const name = declarationName(ts, declaration);
397
- if (!name || !/^use[A-Z0-9]/.test(name.text)) return;
398
- const symbol = checker.getSymbolAtLocation(name);
399
- report(symbol, name);
400
- };
401
- const visit = node => {
402
- if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isVariableDeclaration(node)) inspect(node);
403
- ts.forEachChild(node, visit);
404
- };
405
- visit(sourceFile);
105
+ const HOOK_NAME = /^use[A-Z0-9]/;
106
+
107
+ function reportCustomHook(state, symbol, node) {
108
+ const { ts, checker, seen, violations, config, sourceFile } = state;
109
+ const identity = normalizedSymbolValue(ts, checker, symbol) ?? symbol;
110
+ if (!identity || seen.has(identity) || checker.getTypeOfSymbolAtLocation(identity, node).getCallSignatures().length === 0) return;
111
+ seen.add(identity);
112
+ violations.push(violation(config, sourceFile, node, 'FE_CUSTOM_HOOK_LOCATION',
113
+ 'Every authored custom useX hook declaration belongs under the configured hooks root; calling built-in React hooks inside a visual is still valid.', { ts }));
114
+ }
115
+
116
+ function inspectHookDeclaration(state, declaration) {
117
+ const name = declarationName(state.ts, declaration);
118
+ if (!name || !HOOK_NAME.test(name.text)) return;
119
+ const symbol = state.checker.getSymbolAtLocation(name);
120
+ reportCustomHook(state, symbol, name);
121
+ }
122
+
123
+ function visitHookDeclarations(state, node) {
124
+ const { ts } = state;
125
+ if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isVariableDeclaration(node)) inspectHookDeclaration(state, node);
126
+ ts.forEachChild(node, child => visitHookDeclarations(state, child));
127
+ }
128
+
129
+ function reportExportedHooks(state) {
130
+ const { ts, checker, sourceFile } = state;
406
131
  const module = checker.getSymbolAtLocation(sourceFile);
407
132
  for (const exposed of module ? checker.getExportsOfModule(module) : []) {
408
- if (!/^use[A-Z0-9]/.test(exposed.name)) continue;
133
+ if (!HOOK_NAME.test(exposed.name)) continue;
409
134
  const identity = normalizedSymbolValue(ts, checker, exposed);
410
- for (const declaration of identity?.getDeclarations?.() ?? []) if (declaration.getSourceFile() === sourceFile) {
411
- const node = declarationName(ts, declaration) ?? declaration;
412
- report(identity, node);
135
+ for (const declaration of identity?.getDeclarations?.() ?? []) {
136
+ if (declaration.getSourceFile() !== sourceFile) continue;
137
+ reportCustomHook(state, identity, declarationName(ts, declaration) ?? declaration);
413
138
  }
414
139
  }
415
- return violations;
416
140
  }
417
141
 
418
- function checkPureAndData(config, context, sourceFile, roots) {
142
+ function checkCustomHookLocations(config, context, sourceFile, roots) {
143
+ if (insideAny(roots.hooks, sourceFile.fileName)) return [];
419
144
  const { ts } = context;
420
- const violations = [];
421
- const fileName = path.resolve(sourceFile.fileName);
422
- if (!insideAny(roots.components, fileName)) return violations;
423
- const pure = path.basename(fileName).toLowerCase() === 'component.tsx';
424
- for (const edge of importsForSource(context, fileName)) {
425
- if (!edge.runtime) continue;
426
- const configuredHookBarrel = insideAny(roots.hooks, edge.to) && /^index\.[cm]?[jt]sx?$/i.test(path.basename(edge.to));
427
- const hookChain = configuredHookBarrel ? null
428
- : reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target), { follow: candidate => candidate.reexport && candidate.runtime });
429
- if (hookChain) {
430
- violations.push({ ruleId: 'FE_COMPONENT_DEEP_HOOK_IMPORT', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
431
- specifier: edge.specifier, resolvedPath: relativePath(config.root, hookChain.at(-1)), dependencyChain: hookChain.map(item => relativePath(config.root, item)),
432
- message: 'Components must enter data hooks through the owning hooks barrel.' });
433
- }
434
- if (pure) {
435
- const chain = reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target) || insideAny(roots.transport, target),
436
- { follow: candidate => candidate.reexport && candidate.runtime });
437
- if (chain) violations.push({ ruleId: 'FE_PURE_REACHES_DATA', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
438
- specifier: edge.specifier, resolvedPath: relativePath(config.root, chain.at(-1)), dependencyChain: chain.map(item => relativePath(config.root, item)),
439
- message: 'Pure component.tsx cannot reach hooks or API transport through an alias, relative import, package, or barrel.' });
440
- }
441
- }
442
- if (pure) {
443
- const worldHooks = new Set(['createContext', 'useContext', 'useSyncExternalStore']);
444
- const reactNamespaces = new Set();
445
- for (const statement of sourceFile.statements) {
446
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || statement.importClause?.isTypeOnly) continue;
447
- const source = statement.moduleSpecifier.text;
448
- const named = statement.importClause?.namedBindings;
449
- if (source === 'react' && named && ts.isNamedImports(named)) {
450
- for (const element of named.elements) {
451
- const imported = element.propertyName?.text ?? element.name.text;
452
- if (!element.isTypeOnly && worldHooks.has(imported)) violations.push(violation(config, sourceFile, element, 'FE_PURE_WORLD_HOOK', `Pure component.tsx imports ${imported}; its connected index.tsx owns application/world state.`, { ts }));
453
- }
454
- }
455
- if (source === 'react' && statement.importClause?.name) reactNamespaces.add(statement.importClause.name.text);
456
- if (source === 'react' && named && ts.isNamespaceImport(named)) reactNamespaces.add(named.name.text);
457
- if ((source === 'next/navigation' || source === 'next-intl') && statement.importClause) {
458
- violations.push(violation(config, sourceFile, statement, 'FE_PURE_WORLD_IMPORT', `Pure component.tsx imports ${source}; its connected index.tsx owns router and locale context.`, { ts }));
459
- }
460
- }
461
- const visitWorldHooks = node => {
462
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactNamespaces.has(node.expression.text)
463
- && worldHooks.has(node.name.text)) violations.push(violation(config, sourceFile, node, 'FE_PURE_WORLD_HOOK', `Pure component.tsx accesses ${node.expression.text}.${node.name.text}; its connected index.tsx owns application/world state.`, { ts }));
464
- ts.forEachChild(node, visitWorldHooks);
465
- };
466
- visitWorldHooks(sourceFile);
467
- }
468
- return violations;
145
+ const state = { ts, config, sourceFile, checker: context.checkerFor(sourceFile.fileName), violations: [], seen: new Set() };
146
+ visitHookDeclarations(state, sourceFile);
147
+ reportExportedHooks(state);
148
+ return state.violations;
469
149
  }
470
150
 
151
+ function importsForSource(context, fileName) {
152
+ return context.edges.get(path.resolve(fileName)) ?? [];
153
+ }
471
154
 
472
- function selectedSymbol(ts, checker, expression) {
473
- const selected = unwrapExpression(ts, expression);
474
- if (!selected) return null;
475
- if (ts.isIdentifier(selected)) return checker.getSymbolAtLocation(selected) ?? null;
476
- if (ts.isPropertyAccessExpression(selected)) return checker.getSymbolAtLocation(selected.name) ?? null;
477
- if (ts.isElementAccessExpression(selected) && ts.isStringLiteralLike(selected.argumentExpression)) {
478
- return checker.getSymbolAtLocation(selected.argumentExpression)
479
- ?? checker.getTypeAtLocation(selected.expression).getProperty(selected.argumentExpression.text)
480
- ?? null;
155
+ function inspectComponentEdge(config, context, roots, fileName, pure, edge, violations) {
156
+ if (!edge.runtime) return;
157
+ const configuredHookBarrel = insideAny(roots.hooks, edge.to) && /^index\.[cm]?[jt]sx?$/i.test(path.basename(edge.to));
158
+ const hookChain = configuredHookBarrel ? null
159
+ : reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target), { follow: candidate => candidate.reexport && candidate.runtime });
160
+ if (hookChain) {
161
+ violations.push({ ruleId: 'FE_COMPONENT_DEEP_HOOK_IMPORT', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
162
+ specifier: edge.specifier, resolvedPath: relativePath(config.root, hookChain.at(-1)), dependencyChain: hookChain.map(item => relativePath(config.root, item)),
163
+ message: 'Components must enter data hooks through the owning hooks barrel.' });
481
164
  }
482
- return null;
165
+ if (pure) inspectPureDataReachability(config, context, roots, fileName, edge, violations);
483
166
  }
484
167
 
485
- /* The wider boundary set: `ts.isFunctionLike` (includes call signatures' declarations) rather than the
486
- * four default callable kinds. */
487
- const returnedExpressions = (ts, declaration) => sharedReturnedExpressions(ts, declaration, { functionLike: ts.isFunctionLike });
168
+ function inspectPureDataReachability(config, context, roots, fileName, edge, violations) {
169
+ const chain = reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target) || insideAny(roots.transport, target),
170
+ { follow: candidate => candidate.reexport && candidate.runtime });
171
+ if (chain) violations.push({ ruleId: 'FE_PURE_REACHES_DATA', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
172
+ specifier: edge.specifier, resolvedPath: relativePath(config.root, chain.at(-1)), dependencyChain: chain.map(item => relativePath(config.root, item)),
173
+ message: 'Pure component.tsx cannot reach hooks or API transport through an alias, relative import, package, or barrel.' });
174
+ }
488
175
 
489
- function knownWorldImport(specifier, imported) {
490
- if (specifier === 'next-intl') return WORLD_INTL_CALLS.has(imported);
491
- if (specifier === 'next/navigation' || /(?:^|\/)i18n\/navigation$/.test(specifier)) return WORLD_NAVIGATION_CALLS.has(imported);
492
- if (specifier === 'swr' || specifier === 'swr/immutable' || specifier === 'swr/mutation') return WORLD_SWR_CALLS.has(imported);
493
- if (specifier === 'next-auth/react') return imported === 'useSession';
494
- return false;
176
+ function reportWorldHookImports(config, sourceFile, named, state, violation) {
177
+ const { ts, worldHooks, violations } = state;
178
+ for (const element of named.elements) {
179
+ const imported = element.propertyName?.text ?? element.name.text;
180
+ if (!element.isTypeOnly && worldHooks.has(imported)) violations.push(violation(config, sourceFile, element, 'FE_PURE_WORLD_HOOK', `Pure component.tsx imports ${imported}; its connected index.tsx owns application/world state.`, { ts }));
181
+ }
495
182
  }
496
183
 
497
- function importDeclarationFor(ts, node) {
498
- for (let current = node; current; current = current.parent) if (ts.isImportDeclaration(current)) return current;
499
- return null;
184
+ function recordReactNamespaces(ts, clause, named, reactNamespaces) {
185
+ if (clause?.name) reactNamespaces.add(clause.name.text);
186
+ if (named && ts.isNamespaceImport(named)) reactNamespaces.add(named.name.text);
500
187
  }
501
188
 
502
- function isReactCreateElement(ts, checker, call) {
503
- const expression = unwrapExpression(ts, call.expression);
504
- if (ts.isIdentifier(expression)) {
505
- const symbol = checker.getSymbolAtLocation(expression);
506
- return (symbol?.declarations ?? []).some(declaration => {
507
- const imported = ts.isImportSpecifier(declaration) ? declaration.propertyName?.text ?? declaration.name.text : null;
508
- const parent = importDeclarationFor(ts, declaration);
509
- return imported === 'createElement' && parent && ts.isStringLiteralLike(parent.moduleSpecifier) && parent.moduleSpecifier.text === 'react';
510
- });
189
+ function inspectPureImport(config, sourceFile, statement, state, violation) {
190
+ const { ts, reactNamespaces, violations } = state;
191
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || statement.importClause?.isTypeOnly) return;
192
+ const source = statement.moduleSpecifier.text;
193
+ const named = statement.importClause?.namedBindings;
194
+ if (source === 'react') {
195
+ if (named && ts.isNamedImports(named)) reportWorldHookImports(config, sourceFile, named, state, violation);
196
+ recordReactNamespaces(ts, statement.importClause, named, reactNamespaces);
197
+ }
198
+ if ((source === 'next/navigation' || source === 'next-intl') && statement.importClause) {
199
+ violations.push(violation(config, sourceFile, statement, 'FE_PURE_WORLD_IMPORT', `Pure component.tsx imports ${source}; its connected index.tsx owns router and locale context.`, { ts }));
511
200
  }
512
- if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== 'createElement') return false;
513
- const symbol = checker.getSymbolAtLocation(expression.expression);
514
- return (symbol?.declarations ?? []).some(declaration => {
515
- const parent = importDeclarationFor(ts, declaration);
516
- return (ts.isNamespaceImport(declaration) || ts.isImportClause(declaration))
517
- && parent && ts.isStringLiteralLike(parent.moduleSpecifier) && parent.moduleSpecifier.text === 'react';
518
- });
519
201
  }
520
202
 
521
- function jsxTagName(ts, tagName) {
522
- if (ts.isIdentifier(tagName)) return tagName.text;
523
- if (ts.isPropertyAccessExpression(tagName)) return tagName.name.text;
524
- return null;
203
+ function inspectPureWorldHookAccess(config, sourceFile, node, state, violation) {
204
+ const { ts, worldHooks, reactNamespaces, violations } = state;
205
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactNamespaces.has(node.expression.text)
206
+ && worldHooks.has(node.name.text)) {
207
+ violations.push(violation(config, sourceFile, node, 'FE_PURE_WORLD_HOOK', `Pure component.tsx accesses ${node.expression.text}.${node.name.text}; its connected index.tsx owns application/world state.`, { ts }));
208
+ }
209
+ ts.forEachChild(node, child => inspectPureWorldHookAccess(config, sourceFile, child, state, violation));
525
210
  }
526
211
 
527
- function wrapperTag(ts, tagName) {
528
- const name = jsxTagName(ts, tagName);
529
- return name === 'Suspense' || name === 'SWRConfig' || name === 'Provider'
530
- || Boolean(name && (name.endsWith('Provider') || name.endsWith('ErrorBoundary')));
212
+ function inspectPureModule(config, context, sourceFile, violations, violation) {
213
+ const ts = context.ts;
214
+ const state = { ts, worldHooks: new Set(['createContext', 'useContext', 'useSyncExternalStore']), reactNamespaces: new Set(), violations };
215
+ for (const statement of sourceFile.statements) inspectPureImport(config, sourceFile, statement, state, violation);
216
+ inspectPureWorldHookAccess(config, sourceFile, sourceFile, state, violation);
531
217
  }
532
218
 
533
- function checkWorldRenderBoundaries(config, context, roots) {
534
- const { ts } = context;
219
+ function checkPureAndData(config, context, sourceFile, roots) {
535
220
  const violations = [];
536
- const sourceSet = new Set(context.files.map(source => path.resolve(source.fileName)));
537
- const worldInfoCache = new Map();
538
- const functionWorldCache = new Map();
539
- const renderOutputCache = new Map();
540
- const intrinsicUiHookCache = new Map();
541
-
542
- const intrinsicUiHookFile = (fileName, visiting = new Set()) => {
543
- const resolved = path.resolve(fileName);
544
- if (intrinsicUiHookCache.has(resolved)) return intrinsicUiHookCache.get(resolved);
545
- const hookRoot = rootFor(roots.hooks, resolved);
546
- if (!hookRoot || relativePath(hookRoot, resolved).split('/')[0] !== 'ui' || visiting.has(resolved)) return false;
547
- const sourceFile = context.files.find(source => path.resolve(source.fileName) === resolved);
548
- if (!sourceFile) return false;
549
- intrinsicUiHookCache.set(resolved, false);
550
- const nextVisiting = new Set(visiting).add(resolved);
551
- const checker = context.checkerFor(resolved);
552
- const edges = importsForSource(context, resolved);
553
- let intrinsic = true;
554
- for (const edge of edges.filter(candidate => candidate.runtime)) {
555
- const forbidden = reachableViolation(context.edges, edge, target => insideAny(roots.transport, target)
556
- || insideAny(roots.modules, target) || insideAny(roots.features, target) || insideAny(roots.routes, target)
557
- || (insideAny(roots.hooks, target) && relativePath(rootFor(roots.hooks, target), target).split('/')[0] !== 'ui'),
558
- { follow: candidate => candidate.runtime });
559
- if (forbidden || (insideAny(roots.hooks, edge.to) && !intrinsicUiHookFile(edge.to, nextVisiting))) {
560
- intrinsic = false;
561
- break;
562
- }
563
- }
564
- for (const statement of sourceFile.statements) {
565
- if (!intrinsic) break;
566
- if (!ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly
567
- || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
568
- const specifier = statement.moduleSpecifier.text;
569
- const bindings = [];
570
- if (statement.importClause.name) bindings.push('default');
571
- const named = statement.importClause.namedBindings;
572
- if (named && ts.isNamedImports(named)) for (const element of named.elements) if (!element.isTypeOnly) bindings.push(element.propertyName?.text ?? element.name.text);
573
- if (named && ts.isNamespaceImport(named) && ['next/navigation', 'next-intl', 'swr', 'swr/immutable', 'swr/mutation', 'next-auth/react'].includes(specifier)) { intrinsic = false; break; }
574
- if (bindings.some(imported => knownWorldImport(specifier, imported))) { intrinsic = false; break; }
575
- }
576
- const visit = node => {
577
- if (!intrinsic) return;
578
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'fetch'
579
- && !checker.getSymbolAtLocation(node.expression)) { intrinsic = false; return; }
580
- ts.forEachChild(node, visit);
581
- };
582
- visit(sourceFile);
583
- intrinsicUiHookCache.set(resolved, intrinsic);
584
- return intrinsic;
585
- };
586
-
587
- const intrinsicUiHookSymbol = (symbol, checker) => {
588
- const identity = normalizedSymbolValue(ts, checker, symbol);
589
- const declarations = identity?.getDeclarations?.() ?? [];
590
- return declarations.length > 0 && declarations.every(declaration => intrinsicUiHookFile(declaration.getSourceFile().fileName));
591
- };
592
-
593
- const importInfo = sourceFile => {
594
- const key = path.resolve(sourceFile.fileName);
595
- if (worldInfoCache.has(key)) return worldInfoCache.get(key);
596
- const checker = context.checkerFor(sourceFile.fileName);
597
- const edges = importsForSource(context, sourceFile.fileName);
598
- const byStart = new Map(edges.map(edge => [edge.node.getStart(sourceFile), edge]));
599
- const symbols = new Set();
600
- const namespaces = new Map();
601
- for (const statement of sourceFile.statements) {
602
- if (!ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly
603
- || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
604
- const specifier = statement.moduleSpecifier.text;
605
- const edge = byStart.get(statement.moduleSpecifier.getStart(sourceFile));
606
- const worldEdge = edge && Boolean(reachableViolation(context.edges, edge,
607
- target => insideAny(roots.hooks, target) || insideAny(roots.transport, target),
608
- { follow: candidate => candidate.reexport && candidate.runtime }));
609
- const clause = statement.importClause;
610
- if (clause.name) {
611
- const symbol = checker.getSymbolAtLocation(clause.name);
612
- if (symbol && (worldEdge || knownWorldImport(specifier, 'default'))) symbols.add(symbol);
613
- }
614
- const named = clause.namedBindings;
615
- if (named && ts.isNamedImports(named)) for (const element of named.elements) {
616
- if (element.isTypeOnly) continue;
617
- const imported = element.propertyName?.text ?? element.name.text;
618
- const symbol = checker.getSymbolAtLocation(element.name);
619
- if (symbol && (worldEdge || knownWorldImport(specifier, imported))) symbols.add(symbol);
620
- }
621
- if (named && ts.isNamespaceImport(named)) {
622
- const symbol = checker.getSymbolAtLocation(named.name);
623
- if (symbol) namespaces.set(symbol, { specifier, worldEdge });
624
- }
625
- }
626
- const value = { checker, symbols, namespaces };
627
- worldInfoCache.set(key, value);
628
- return value;
629
- };
630
-
631
- const expressionFunctions = (expression, checker, seen = new Set()) => {
632
- expression = unwrapExpression(ts, expression);
633
- if (!expression) return [];
634
- if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) || ts.isMethodDeclaration(expression)
635
- || ts.isFunctionDeclaration(expression)) return [expression];
636
- if (ts.isConditionalExpression(expression)) {
637
- return [...expressionFunctions(expression.whenTrue, checker, seen), ...expressionFunctions(expression.whenFalse, checker, seen)];
638
- }
639
- if (ts.isCallExpression(expression)) {
640
- const selected = unwrapExpression(ts, expression.expression);
641
- const name = ts.isIdentifier(selected) ? selected.text
642
- : ts.isPropertyAccessExpression(selected) ? selected.name.text : null;
643
- if (['forwardRef', 'memo'].includes(name) && expression.arguments[0]) return expressionFunctions(expression.arguments[0], checker, seen);
644
- }
645
- const symbol = normalizedSymbolValue(ts, checker, selectedSymbol(ts, checker, expression));
646
- if (!symbol || seen.has(symbol)) return [];
647
- const nextSeen = new Set(seen).add(symbol);
648
- const functions = [];
649
- for (const declaration of symbol.getDeclarations?.() ?? []) {
650
- if (ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration)) functions.push(declaration);
651
- else if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
652
- functions.push(...expressionFunctions(declaration.initializer, checker, nextSeen));
653
- } else if (ts.isClassDeclaration(declaration)) {
654
- functions.push(...declaration.members.filter(member => ts.isMethodDeclaration(member)
655
- && member.name && (ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name)) && member.name.text === 'render'));
656
- }
657
- }
658
- return [...new Set(functions.filter(fn => fn.body))];
659
- };
660
-
661
- const symbolIsWorld = (raw, sourceFile, propertyName = null) => {
662
- if (!raw) return false;
663
- const info = importInfo(sourceFile);
664
- if (intrinsicUiHookSymbol(raw, info.checker)) return false;
665
- if (info.symbols.has(raw)) return true;
666
- for (const [namespace, declaration] of info.namespaces) if (namespace === raw) {
667
- return declaration.worldEdge || knownWorldImport(declaration.specifier, propertyName ?? '');
668
- }
669
- const identity = normalizedSymbolValue(ts, info.checker, raw);
670
- return (identity?.getDeclarations?.() ?? []).some(declaration => {
671
- const fileName = declaration.getSourceFile().fileName;
672
- return insideAny(roots.hooks, fileName) || insideAny(roots.transport, fileName);
673
- });
674
- };
675
-
676
- const namespaceIsWorld = (raw, sourceFile, propertyName) => {
677
- const info = importInfo(sourceFile);
678
- const declaration = info.namespaces.get(raw);
679
- return Boolean(declaration && (declaration.worldEdge || knownWorldImport(declaration.specifier, propertyName ?? '')));
680
- };
681
-
682
- const calleeIsWorld = (input, checker, sourceFile, seen = new Set()) => {
683
- const expression = unwrapExpression(ts, input);
684
- if (ts.isIdentifier(expression) && symbolIsWorld(checker.getSymbolAtLocation(expression), sourceFile)) return true;
685
- if ((ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression))) {
686
- const property = ts.isPropertyAccessExpression(expression) ? expression.name.text
687
- : ts.isStringLiteralLike(expression.argumentExpression) ? expression.argumentExpression.text : null;
688
- const namespace = checker.getSymbolAtLocation(expression.expression);
689
- if (intrinsicUiHookSymbol(selectedSymbol(ts, checker, expression), checker)) return false;
690
- if (namespaceIsWorld(namespace, sourceFile, property)) return true;
691
- if (symbolIsWorld(selectedSymbol(ts, checker, expression), sourceFile)) return true;
692
- }
693
- const symbol = normalizedSymbolValue(ts, checker, selectedSymbol(ts, checker, expression));
694
- if (!symbol || seen.has(symbol)) return false;
695
- const nextSeen = new Set(seen).add(symbol);
696
- const declarations = symbol.getDeclarations?.() ?? [];
697
- for (const declaration of declarations) {
698
- if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
699
- if (calleeIsWorld(declaration.initializer, checker, declaration.getSourceFile(), nextSeen)) return true;
700
- }
701
- }
702
- return expressionFunctions(expression, checker).some(fn => functionUsesWorld(fn, checker, nextSeen));
703
- };
704
-
705
- const callIsWorld = (call, checker, sourceFile, seen) => calleeIsWorld(call.expression, checker, sourceFile, seen);
706
-
707
- const functionUsesWorld = (fn, checker, seen = new Set()) => {
708
- if (functionWorldCache.has(fn)) return functionWorldCache.get(fn);
709
- functionWorldCache.set(fn, false);
710
- let found = false;
711
- const sourceFile = fn.getSourceFile();
712
- const visit = node => {
713
- if (found) return;
714
- if (ts.isCallExpression(node) && callIsWorld(node, checker, sourceFile, seen)) { found = true; return; }
715
- ts.forEachChild(node, visit);
716
- };
717
- visit(fn.body ?? fn);
718
- functionWorldCache.set(fn, found);
719
- return found;
720
- };
721
-
722
- const expressionHasRender = (expression, checker, seen = new Set()) => {
723
- expression = unwrapExpression(ts, expression);
724
- if (!expression) return false;
725
- if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression) || ts.isJsxFragment(expression)) return true;
726
- if (ts.isCallExpression(expression) && isReactCreateElement(ts, checker, expression)) return true;
727
- if (ts.isConditionalExpression(expression)) return expressionHasRender(expression.whenTrue, checker, seen)
728
- || expressionHasRender(expression.whenFalse, checker, seen);
729
- if (ts.isBinaryExpression(expression) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken,
730
- ts.SyntaxKind.QuestionQuestionToken].includes(expression.operatorToken.kind)) {
731
- return expressionHasRender(expression.left, checker, seen) || expressionHasRender(expression.right, checker, seen);
732
- }
733
- if (ts.isCallExpression(expression) && (ts.isPropertyAccessExpression(expression.expression)
734
- || ts.isElementAccessExpression(expression.expression))) {
735
- const name = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name.text
736
- : ts.isStringLiteralLike(expression.expression.argumentExpression) ? expression.expression.argumentExpression.text : null;
737
- if (name === 'map' && expression.arguments[0]) return expressionFunctions(expression.arguments[0], checker)
738
- .some(fn => functionHasRender(fn, checker, seen));
739
- }
740
- const symbol = normalizedSymbolValue(ts, checker, selectedSymbol(ts, checker, ts.isCallExpression(expression) ? expression.expression : expression));
741
- if (!symbol || seen.has(symbol)) return false;
742
- const nextSeen = new Set(seen).add(symbol);
743
- if (ts.isIdentifier(expression)) for (const declaration of symbol.getDeclarations?.() ?? []) {
744
- if (ts.isVariableDeclaration(declaration) && declaration.initializer && expressionHasRender(declaration.initializer, checker, nextSeen)) return true;
745
- }
746
- if (ts.isCallExpression(expression)) return expressionFunctions(expression.expression, checker, seen)
747
- .some(fn => functionHasRender(fn, checker, nextSeen));
748
- return false;
749
- };
750
-
751
- const functionHasRender = (fn, checker, seen = new Set()) => {
752
- if (renderOutputCache.has(fn)) return renderOutputCache.get(fn);
753
- renderOutputCache.set(fn, false);
754
- const found = returnedExpressions(ts, fn).some(expression => expressionHasRender(expression, checker, seen));
755
- renderOutputCache.set(fn, found);
756
- return found;
757
- };
758
-
759
- const capturesOuterFunction = (fn, checker) => {
760
- const outers = [];
761
- for (let parent = fn.parent; parent; parent = parent.parent) if (ts.isFunctionLike(parent)) outers.push(parent);
762
- if (!outers.length) return false;
763
- let captured = false;
764
- const visit = node => {
765
- if (captured) return;
766
- if (ts.isIdentifier(node)) {
767
- const symbol = checker.getSymbolAtLocation(node);
768
- for (const declaration of symbol?.getDeclarations?.() ?? []) {
769
- if (declaration.pos >= fn.pos && declaration.end <= fn.end) continue;
770
- if (outers.some(outer => declaration.pos >= outer.pos && declaration.end <= outer.end)) { captured = true; return; }
771
- }
772
- }
773
- ts.forEachChild(node, visit);
774
- };
775
- visit(fn.body ?? fn);
776
- return captured;
777
- };
778
-
779
- const pureRenderTarget = (expression, checker, expectedFile = null) => {
780
- const functions = expressionFunctions(expression, checker);
781
- return functions.length > 0 && functions.every(fn => sourceSet.has(path.resolve(fn.getSourceFile().fileName))
782
- && (insideAny(roots.components, fn.getSourceFile().fileName) || insideAny(roots.features, fn.getSourceFile().fileName))
783
- && (!expectedFile || path.resolve(fn.getSourceFile().fileName) === expectedFile)
784
- && functionHasRender(fn, checker) && !functionUsesWorld(fn, checker) && !capturesOuterFunction(fn, checker));
785
- };
786
-
787
- const expressionSuppliesRender = (expression, checker) => expressionHasRender(expression, checker)
788
- || expressionFunctions(expression, checker).some(fn => functionHasRender(fn, checker));
789
-
790
- const routedLayoutChild = (opening, child, checker) => {
791
- if (!ts.isJsxExpression(child) || !child.expression) return false;
792
- const source = opening.getSourceFile().fileName;
793
- if (!/^apps\/[^/]+\/src\/features\/layouts\/[^/]+\/index\.tsx$/.test(relativePath(config.root, source))) return false;
794
- const sibling = path.resolve(path.dirname(source), 'component.tsx');
795
- if (!pureRenderTarget(opening.tagName, checker, sibling)) return false;
796
- const value = unwrapExpression(ts, child.expression);
797
- if (!ts.isPropertyAccessExpression(value) || !['content', 'children'].includes(value.name.text)) return false;
798
- const receiver = unwrapExpression(ts, value.expression);
799
- if (!ts.isIdentifier(receiver)) return false;
800
- const symbol = checker.getSymbolAtLocation(receiver);
801
- return (symbol?.getDeclarations?.() ?? []).some(declaration => ts.isParameter(declaration));
802
- };
803
-
804
- const renderBoundary = (expression, checker, seen = new Set(), allowEmpty = false, expectedFile = null) => {
805
- expression = unwrapExpression(ts, expression);
806
- if (!expression) return allowEmpty;
807
- if (expression.kind === ts.SyntaxKind.NullKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) return allowEmpty;
808
- if (ts.isConditionalExpression(expression)) return renderBoundary(expression.whenTrue, checker, seen, allowEmpty, expectedFile)
809
- && renderBoundary(expression.whenFalse, checker, seen, allowEmpty, expectedFile);
810
- if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
811
- return renderBoundary(expression.right, checker, seen, true, expectedFile);
812
- }
813
- if (ts.isArrayLiteralExpression(expression)) return expression.elements.length > 0
814
- && expression.elements.every(item => renderBoundary(item, checker, seen, allowEmpty, expectedFile));
815
- if (ts.isIdentifier(expression)) {
816
- const symbol = normalizedSymbolValue(ts, checker, checker.getSymbolAtLocation(expression));
817
- if (!symbol || seen.has(symbol)) return false;
818
- const nextSeen = new Set(seen).add(symbol);
819
- const values = (symbol.getDeclarations?.() ?? []).flatMap(declaration => ts.isVariableDeclaration(declaration) && declaration.initializer
820
- ? [declaration.initializer] : []);
821
- if (values.length) return values.every(value => renderBoundary(value, checker, nextSeen, allowEmpty, expectedFile));
822
- }
823
- if (ts.isCallExpression(expression)) {
824
- if (isReactCreateElement(ts, checker, expression)) {
825
- const target = expression.arguments[0] ? unwrapExpression(ts, expression.arguments[0]) : null;
826
- if (!target || ts.isStringLiteralLike(target)) return false;
827
- const pureTarget = pureRenderTarget(target, checker, expectedFile) && !wrapperTag(ts, target);
828
- const wrapper = wrapperTag(ts, target);
829
- if (!pureTarget && !wrapper) return false;
830
- const props = expression.arguments[1] ? unwrapExpression(ts, expression.arguments[1]) : null;
831
- let hasBoundary = pureTarget;
832
- if (props && props.kind !== ts.SyntaxKind.NullKeyword) {
833
- if (!ts.isObjectLiteralExpression(props) || props.properties.some(property => ts.isSpreadAssignment(property))) return false;
834
- for (const property of props.properties) {
835
- if (!ts.isPropertyAssignment(property)) continue;
836
- const value = unwrapExpression(ts, property.initializer);
837
- if (!expressionSuppliesRender(value, checker)) continue;
838
- if (!pureRenderTarget(value, checker, expectedFile) && !renderBoundary(value, checker, seen, true, expectedFile)) return false;
839
- const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
840
- if (['children', 'component', 'content', 'render', 'view'].includes(name)) hasBoundary = true;
841
- }
842
- }
843
- const children = expression.arguments.slice(2);
844
- if (children.length) {
845
- if (!children.every(child => renderBoundary(child, checker, seen, false, expectedFile))) return false;
846
- hasBoundary = true;
847
- }
848
- return hasBoundary;
849
- }
850
- if ((ts.isPropertyAccessExpression(expression.expression) || ts.isElementAccessExpression(expression.expression))) {
851
- const name = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name.text
852
- : ts.isStringLiteralLike(expression.expression.argumentExpression) ? expression.expression.argumentExpression.text : null;
853
- if (name === 'map' && expression.arguments[0]) {
854
- const renderers = expressionFunctions(expression.arguments[0], checker);
855
- return renderers.length > 0 && renderers.every(fn => !functionUsesWorld(fn, checker)
856
- && returnedExpressions(ts, fn).length > 0
857
- && returnedExpressions(ts, fn).every(value => renderBoundary(value, checker, seen, true, expectedFile)));
858
- }
859
- }
860
- return pureRenderTarget(expression.expression, checker, expectedFile);
861
- }
862
- if (ts.isJsxFragment(expression)) {
863
- const children = expression.children.filter(child => !ts.isJsxText(child) || child.text.trim());
864
- return children.length > 0 && children.every(child => ts.isJsxExpression(child)
865
- ? renderBoundary(child.expression, checker, seen, false, expectedFile)
866
- : ts.isJsxText(child) ? false : renderBoundary(child, checker, seen, false, expectedFile));
867
- }
868
- if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression)) {
869
- const opening = ts.isJsxElement(expression) ? expression.openingElement : expression;
870
- const pureTarget = pureRenderTarget(opening.tagName, checker, expectedFile) && !wrapperTag(ts, opening.tagName);
871
- const wrapper = wrapperTag(ts, opening.tagName);
872
- if (!pureTarget && !wrapper) return false;
873
- const children = ts.isJsxElement(expression)
874
- ? expression.children.filter(child => !ts.isJsxText(child) || child.text.trim()) : [];
875
- let hasBoundary = pureTarget;
876
- if (children.length) {
877
- // A layout may hand its one opaque router-supplied child to its pure sibling.
878
- // Extra drawing still has to satisfy the ordinary resolved render boundary.
879
- const routedChild = pureTarget && children.length === 1 && routedLayoutChild(opening, children[0], checker);
880
- if (!routedChild && !children.every(child => ts.isJsxExpression(child)
881
- ? renderBoundary(child.expression, checker, seen, false, expectedFile)
882
- : ts.isJsxText(child) ? false : renderBoundary(child, checker, seen, false, expectedFile))) return false;
883
- hasBoundary = true;
884
- }
885
- for (const attribute of opening.attributes.properties) {
886
- if (!ts.isJsxAttribute(attribute) || !attribute.initializer) continue;
887
- const name = attribute.name.text;
888
- if (['fallback', 'errorElement'].includes(name)) {
889
- if (ts.isStringLiteral(attribute.initializer)) return false;
890
- if (ts.isJsxExpression(attribute.initializer)
891
- && !renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
892
- }
893
- if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
894
- && expressionSuppliesRender(attribute.initializer.expression, checker)) {
895
- if (!pureRenderTarget(attribute.initializer.expression, checker, expectedFile)
896
- && !renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
897
- if (['component', 'content', 'render', 'view'].includes(name)) hasBoundary = true;
898
- }
899
- }
900
- return hasBoundary;
901
- }
902
- return false;
903
- };
904
-
905
- for (const sourceFile of context.files) {
906
- if ((!insideAny(roots.components, sourceFile.fileName) && !insideAny(roots.features, sourceFile.fileName))
907
- || !/\.[cm]?tsx$/i.test(sourceFile.fileName)) continue;
908
- const checker = context.checkerFor(sourceFile.fileName);
909
- const tier = tierOf(roots.components, sourceFile.fileName);
910
- const functions = [];
911
- const collect = node => {
912
- if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) functions.push(node);
913
- ts.forEachChild(node, collect);
914
- };
915
- collect(sourceFile);
916
- for (const fn of functions) {
917
- const name = declarationName(ts, fn);
918
- if (tier?.tier === 'blocks' && name && /^use[A-Z0-9]/.test(name.text) && functionUsesWorld(fn, checker)) {
919
- violations.push(violation(config, sourceFile, name, 'FE_BLOCK_PRODUCT_HOOK_DEFINITION',
920
- 'A block may call a product hook from hooks but cannot define product-world or data lifecycle under the block.', { ts }));
921
- }
922
- }
923
- for (const fn of functions) {
924
- if (!functionHasRender(fn, checker) || !functionUsesWorld(fn, checker)) continue;
925
- const returned = returnedExpressions(ts, fn);
926
- if (!returned.length || returned.some(expression => !renderBoundary(expression, checker, new Set(), true))) {
927
- violations.push(violation(config, sourceFile, fn, 'FE_WORLD_OWNER_RENDER_BOUNDARY',
928
- 'A visual owner that reads product/world state must hand every render path to a statically resolved pure render function or component; intrinsic-only interaction does not select this rule.', { ts }));
929
- }
930
- if (tier && LOWER_COMPONENT_TIERS.has(tier.tier)) {
931
- violations.push(violation(config, sourceFile, fn, 'FE_COMPONENT_WORLD_OWNERSHIP',
932
- `${tier.tier} may own intrinsic browser interaction but cannot own product-world, navigation, locale, session or data lifecycle.`, { ts }));
933
- }
934
- if (tier?.tier === 'blocks') {
935
- const expected = path.resolve(path.dirname(sourceFile.fileName), 'component.tsx');
936
- const index = path.basename(sourceFile.fileName).toLowerCase() === 'index.tsx';
937
- if (!index || !sourceSet.has(expected) || !returned.length
938
- || returned.some(expression => !renderBoundary(expression, checker, new Set(), true, expected))) {
939
- violations.push(violation(config, sourceFile, fn, 'FE_CONNECTED_BLOCK_RENDER_PAIR',
940
- 'A connected blocks/index.tsx must hand every nonempty render path to a resolved pure export from its sibling component.tsx; pure blocks and lower tiers need no twin.', { ts }));
941
- }
942
- }
943
- }
944
- }
221
+ const fileName = path.resolve(sourceFile.fileName);
222
+ if (!insideAny(roots.components, fileName)) return violations;
223
+ const pure = path.basename(fileName).toLowerCase() === 'component.tsx';
224
+ for (const edge of importsForSource(context, fileName)) inspectComponentEdge(config, context, roots, fileName, pure, edge, violations);
225
+ if (pure) inspectPureModule(config, context, sourceFile, violations, violation);
945
226
  return violations;
946
227
  }
947
228
 
229
+
948
230
  /** Enforce route adapters, UI responsibility tiers, pure/connected, and transport boundaries. */
949
231
  export function checkFrontend(config, context) {
950
232
  const roots = {
@@ -957,15 +239,13 @@ export function checkFrontend(config, context) {
957
239
  };
958
240
  roots.sourceRoots = [...new Set(roots.routes.map(root => path.dirname(root)))];
959
241
  roots.pinnedRootFiles = frameworkPinnedRootFiles();
960
- const violations = checkGrammar(config, context);
242
+ const violations = checkGrammar(config, context, violation);
961
243
  for (const sourceFile of context.files) {
962
244
  if (insideAny(roots.routes, sourceFile.fileName) && path.basename(sourceFile.fileName).toLowerCase() === 'page.tsx') {
963
- violations.push(...checkRoute(config, context, sourceFile, roots));
245
+ violations.push(...checkRoute(config, context, sourceFile, roots, { violation, insideAny, importsForSource }));
964
246
  }
965
- violations.push(...checkFrontendSourceLayout(config, sourceFile, roots));
966
- violations.push(...checkCustomHookLocations(config, context, sourceFile, roots));
967
- violations.push(...checkPureAndData(config, context, sourceFile, roots));
247
+ violations.push(...checkFrontendSourceLayout(config, sourceFile, roots), ...checkCustomHookLocations(config, context, sourceFile, roots), ...checkPureAndData(config, context, sourceFile, roots));
968
248
  }
969
- violations.push(...checkWorldRenderBoundaries(config, context, roots));
249
+ violations.push(...checkWorldRenderBoundaries(config, context, roots, { insideAny, rootFor, tierOf, violation, declarationName, importsForSource }));
970
250
  return violations;
971
251
  }