@starci/hfs 4.0.10 → 4.2.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 (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -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/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { machineKit, pascal, upperSnake } from './machine-ast.mjs';
4
4
  import { locateDeclaration } from '../slots.mjs';
5
+ import { byCodeUnit } from '../../lib/list.mjs';
5
6
 
6
7
  /**
7
8
  * R84 `connection-map` (BE_CONNECTION_DUPLICATE), folding RED01 and RED02. One physical database is one connection, one
@@ -33,191 +34,282 @@ const SOURCE_CALLS = new Set(['getDataSourceToken', 'InjectDataSource']);
33
34
  const ENV_KEY = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u;
34
35
  const INJECTOR_NAME = /^Inject[A-Za-z0-9]*EntityManager$/u;
35
36
 
37
+ const exportedInjectorName = (ts, kit, node) => {
38
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) return node.name.text;
39
+ if (ts.isFunctionDeclaration(node) && kit.isExported(node) && node.name) return node.name.text;
40
+ return null;
41
+ };
42
+
43
+ const ENV_ASSIGNMENT = /^(?:export\s+)?([A-Za-z_]\w*)\s*=/u;
44
+ const LINE_BREAK = /[\n\r\u2028\u2029]/u;
45
+
46
+ // One `KEY=value` line: a value holding a line break after its first character is not an assignment.
47
+ function parseEnvLine(raw) {
48
+ const line = raw.trim();
49
+ if (!line || line.startsWith('#')) return null;
50
+ const match = ENV_ASSIGNMENT.exec(line);
51
+ if (!match) return null;
52
+ let value = line.slice(match[0].length).trim();
53
+ if (LINE_BREAK.test(value)) return null;
54
+ if (/^(['"]).*\1$/u.test(value)) value = value.slice(1, -1);
55
+ return [match[1], value.trim()];
56
+ }
57
+
58
+ function readEnvFile(file, merged) {
59
+ let text;
60
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
61
+ for (const raw of text.split(/\r?\n/u)) {
62
+ const parsed = parseEnvLine(raw);
63
+ if (parsed) merged.set(parsed[0], parsed[1]);
64
+ }
65
+ }
66
+
36
67
  function readEnvFiles(target) {
37
68
  const merged = new Map();
38
- const readFile = file => {
39
- let text;
40
- try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
41
- for (const raw of text.split(/\r?\n/u)) {
42
- const line = raw.trim();
43
- if (!line || line.startsWith('#')) continue;
44
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
45
- if (!match) continue;
46
- let value = match[2].trim();
47
- if (/^(['"]).*\1$/u.test(value)) value = value.slice(1, -1);
48
- merged.set(match[1], value.trim());
49
- }
50
- };
51
69
  let stat;
52
70
  try { stat = fs.statSync(target); } catch { return null; }
53
- if (stat.isFile()) readFile(target);
71
+ if (stat.isFile()) readEnvFile(target, merged);
54
72
  else for (const entry of fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
55
- if (entry.isFile() && !entry.name.toLowerCase().endsWith('.md')) readFile(path.join(target, entry.name));
73
+ if (entry.isFile() && !entry.name.toLowerCase().endsWith('.md')) readEnvFile(path.join(target, entry.name), merged);
56
74
  }
57
75
  return merged;
58
76
  }
59
77
 
60
- export function checkConnectionMap(input) {
61
- const { config, graph } = input;
62
- const kit = machineKit(input);
63
- const { ts } = kit;
64
- const declared = config.hfs.repo.connections ?? [];
65
- const byName = new Map(declared.map(connection => [connection.name, connection]));
66
- const violations = [];
67
- const report = (rel, message, extra = {}) => violations.push({ ruleId: RULE, path: rel, message, ...extra });
68
- const decoratorsFile = name => `${DATABASE_DIR}/${name}.decorators.ts`;
69
- const databaseFiles = [...graph.files.values()].filter(file => file.rel.startsWith(`${DATABASE_DIR}/`));
78
+ const exportsVariable = (ts, kit, item, name) => ts.isVariableStatement(item) && kit.isExported(item)
79
+ && item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name);
70
80
 
71
- // 1. The three files of each declared connection, and no file of an undeclared one.
72
- for (const connection of declared) {
73
- const connectionRel = `${DATABASE_DIR}/${connection.name}.connection.ts`;
74
- const constant = `${upperSnake(connection.name)}_CONNECTION`;
75
- const connectionFile = kit.graphFile(connectionRel);
76
- if (!connectionFile) report(connectionRel, `hfs.json declares connection ${connection.name} but ${connectionRel} does not exist; declare \`export const ${constant} = "${connection.name}"\` there.`, { connection: connection.name });
77
- else {
78
- const checker = kit.checkerOf(connectionFile.sourceFile);
79
- const statement = connectionFile.sourceFile.statements.find(item => ts.isVariableStatement(item) && kit.isExported(item)
80
- && item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === constant));
81
- const initializer = statement?.declarationList.declarations.find(declaration => declaration.name.text === constant)?.initializer;
82
- if (!statement || kit.stringValue(checker, initializer) !== connection.name) {
83
- report(connectionRel, `${connectionRel} must export \`${constant} = "${connection.name}"\` (the connection name declared in hfs.json).`, { connection: connection.name });
84
- }
81
+ const checkConnectionFile = (connection, kit, ts, report) => {
82
+ const connectionRel = `${DATABASE_DIR}/${connection.name}.connection.ts`;
83
+ const constant = `${upperSnake(connection.name)}_CONNECTION`;
84
+ const connectionFile = kit.graphFile(connectionRel);
85
+ if (!connectionFile) {
86
+ report(connectionRel, `hfs.json declares connection ${connection.name} but ${connectionRel} does not exist; declare \`export const ${constant} = "${connection.name}"\` there.`, { connection: connection.name });
87
+ return;
88
+ }
89
+ const checker = kit.checkerOf(connectionFile.sourceFile);
90
+ const statement = connectionFile.sourceFile.statements.find(item => exportsVariable(ts, kit, item, constant));
91
+ const initializer = statement?.declarationList.declarations.find(declaration => declaration.name.text === constant)?.initializer;
92
+ if (!statement || kit.stringValue(checker, initializer) !== connection.name) {
93
+ report(connectionRel, `${connectionRel} must export \`${constant} = "${connection.name}"\` (the connection name declared in hfs.json).`, { connection: connection.name });
94
+ }
95
+ };
96
+
97
+ const checkInjectorFile = (connection, kit, ts, report, decoratorsFile) => {
98
+ const decorators = kit.graphFile(decoratorsFile(connection.name));
99
+ const injector = `Inject${pascal(connection.name)}EntityManager`;
100
+ const exported = decorators?.sourceFile.statements.some(item => exportsVariable(ts, kit, item, injector)
101
+ || (ts.isFunctionDeclaration(item) && kit.isExported(item) && item.name?.text === injector));
102
+ if (!exported) report(decoratorsFile(connection.name), `Connection ${connection.name} needs ${decoratorsFile(connection.name)} exporting ${injector}, the one injector of its shared EntityManager.`, { connection: connection.name });
103
+ };
104
+
105
+ const isProcessEnvAccess = (ts, node) => ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
106
+ && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process';
107
+
108
+ const configEnvKey = (ts, node) => {
109
+ if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) return node.text;
110
+ if (isProcessEnvAccess(ts, node) && ENV_KEY.test(node.name.text)) return node.name.text;
111
+ return null;
112
+ };
113
+
114
+ const checkConfigFile = (connection, declared, kit, ts, report, configRel, configFile) => {
115
+ let readsSchema = false;
116
+ kit.walk(configFile.sourceFile, node => {
117
+ const key = configEnvKey(ts, node);
118
+ if (key === null) return true;
119
+ if (key === `${connection.envPrefix}_SCHEMA`) readsSchema = true;
120
+ const own = key.startsWith(`${connection.envPrefix}_`);
121
+ const foreign = declared.some(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
122
+ if (!own || foreign) {
123
+ report(configRel, `${configRel} reads ${key}; the config of connection ${connection.name} reads only ${connection.envPrefix}_* keys.`, { ...kit.at(configRel, configFile.sourceFile, node), connection: connection.name });
85
124
  }
86
- const decorators = kit.graphFile(decoratorsFile(connection.name));
87
- const injector = `Inject${pascal(connection.name)}EntityManager`;
88
- const exported = decorators?.sourceFile.statements.some(item => (ts.isVariableStatement(item) && kit.isExported(item)
89
- && item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === injector))
90
- || (ts.isFunctionDeclaration(item) && kit.isExported(item) && item.name?.text === injector));
91
- if (!exported) report(decoratorsFile(connection.name), `Connection ${connection.name} needs ${decoratorsFile(connection.name)} exporting ${injector}, the one injector of its shared EntityManager.`, { connection: connection.name });
125
+ return false;
126
+ });
127
+ if (connection.isolation === 'schema' && !readsSchema) report(configRel, `Connection ${connection.name} is isolated as a schema of a shared database (hfs.json isolation), so ${configRel} must read ${connection.envPrefix}_SCHEMA and hand it to the data source; splitting the context out into its own database is then an env change only.`, { connection: connection.name });
128
+ };
129
+
130
+ const checkDeclaredConnections = (declared, kit, ts, report, decoratorsFile) => {
131
+ for (const connection of declared) {
132
+ checkConnectionFile(connection, kit, ts, report);
133
+ checkInjectorFile(connection, kit, ts, report, decoratorsFile);
92
134
  const configRel = `${DATABASE_DIR}/${connection.name}.config.ts`;
93
135
  const configFile = kit.graphFile(configRel);
94
136
  if (!configFile) {
95
137
  report(configRel, `Connection ${connection.name} needs ${configRel}, the one place its ${connection.envPrefix}_* keys are read.`, { connection: connection.name });
96
138
  continue;
97
139
  }
98
- let readsSchema = false;
99
- kit.walk(configFile.sourceFile, node => {
100
- let key = null;
101
- if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) key = node.text;
102
- else if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
103
- && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process' && ENV_KEY.test(node.name.text)) key = node.name.text;
104
- if (key === null) return true;
105
- if (key === `${connection.envPrefix}_SCHEMA`) readsSchema = true;
106
- const own = key.startsWith(`${connection.envPrefix}_`);
107
- const foreign = declared.find(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
108
- if (!own || foreign) {
109
- report(configRel, `${configRel} reads ${key}; the config of connection ${connection.name} reads only ${connection.envPrefix}_* keys.`, { ...kit.at(configRel, configFile.sourceFile, node), connection: connection.name });
110
- }
111
- return false;
112
- });
113
- if (connection.isolation === 'schema' && !readsSchema) report(configRel, `Connection ${connection.name} is isolated as a schema of a shared database (hfs.json isolation), so ${configRel} must read ${connection.envPrefix}_SCHEMA and hand it to the data source; splitting the context out into its own database is then an env change only.`, { connection: connection.name });
140
+ checkConfigFile(connection, declared, kit, ts, report, configRel, configFile);
114
141
  }
142
+ };
143
+
144
+ const reportUndeclaredDatabaseFiles = (databaseFiles, byName, report) => {
115
145
  for (const file of databaseFiles) {
116
146
  const match = /^(.+)\.(connection|decorators|config)\.ts$/u.exec(path.posix.basename(file.rel));
117
147
  if (match && path.posix.dirname(file.rel) === DATABASE_DIR && !byName.has(match[1])) {
118
148
  report(file.rel, `${file.rel} belongs to connection ${match[1]}, which hfs.json does not declare; declare the database in hfs.json connections or delete the file.`, { connection: match[1] });
119
149
  }
120
150
  }
151
+ };
121
152
 
122
- // 2. Every entity manager token and injector in the program: one home per connection.
153
+ const inspectManagerToken = (file, checker, node, binding, state) => {
154
+ const { kit, byName, decoratorsFile, report, perDecorators } = state;
155
+ const value = kit.stringValue(checker, node.arguments[0]);
156
+ const home = value === null ? null : decoratorsFile(value);
157
+ if (value === null || !byName.has(value)) {
158
+ const target = value === null ? 'a connection that cannot be resolved to a constant' : `connection ${value}, which hfs.json does not declare`;
159
+ report(file.rel, `${binding.name}() names ${target}; only the declared connections have an entity manager token.`, kit.at(file.rel, file.sourceFile, node));
160
+ } else if (file.rel !== home) {
161
+ report(file.rel, `${binding.name}(${value}) belongs in ${home} only; inject the shared manager with Inject${pascal(value)}EntityManager() instead of a second path to connection ${value}.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
162
+ } else {
163
+ perDecorators.set(value, (perDecorators.get(value) ?? 0) + 1);
164
+ if (perDecorators.get(value) > 1) report(file.rel, `${home} resolves connection ${value} more than once; one connection has one injector.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
165
+ }
166
+ };
167
+
168
+ const inspectDataSourceCall = (file, node, binding, state) => {
169
+ const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || state.slotIs(file, 'be.app.cli') || state.slotIs(file, 'be.cli') || state.slotIs(file, 'be.tests.fixtures');
170
+ if (!allowed) state.report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the cli; inject the shared EntityManager of the connection instead.`, state.kit.at(file.rel, file.sourceFile, node));
171
+ };
172
+
173
+ const inspectTypeormCall = (file, checker, node, state) => {
174
+ const binding = state.kit.importBinding(checker, node.expression);
175
+ if (binding?.module === TYPEORM && MANAGER_CALLS.has(binding.name)) inspectManagerToken(file, checker, node, binding, state);
176
+ else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) inspectDataSourceCall(file, node, binding, state);
177
+ };
178
+
179
+ const inspectInjectorExport = (file, node, state) => {
180
+ const { ts, kit, byName, decoratorsFile, report } = state;
181
+ const exportedName = exportedInjectorName(ts, kit, node);
182
+ if (!exportedName || !INJECTOR_NAME.test(exportedName)) return;
183
+ const home = [...byName.keys()].find(name => `Inject${pascal(name)}EntityManager` === exportedName);
184
+ if (!home || file.rel !== decoratorsFile(home)) {
185
+ report(file.rel, `${exportedName} is exported here, but the only injector of an entity manager is Inject<Conn>EntityManager in src/modules/platform/database/<conn>.decorators.ts, one per declared connection.`, kit.at(file.rel, file.sourceFile, node));
186
+ }
187
+ };
188
+
189
+ const inspectManagerNode = (file, checker, node, state) => {
190
+ if (state.ts.isCallExpression(node)) inspectTypeormCall(file, checker, node, state);
191
+ inspectInjectorExport(file, node, state);
192
+ return true;
193
+ };
194
+
195
+ const checkManagerCalls = (graph, state) => {
123
196
  const perDecorators = new Map();
197
+ const walkState = { ...state, perDecorators, slotIs: (file, id) => file.slot === id };
124
198
  for (const file of graph.files.values()) {
125
- const checker = kit.checkerOf(file.sourceFile);
126
- const slotIs = id => file.slot === id;
127
- kit.walk(file.sourceFile, node => {
128
- if (ts.isCallExpression(node)) {
129
- const binding = kit.importBinding(checker, node.expression);
130
- if (binding?.module === TYPEORM && MANAGER_CALLS.has(binding.name)) {
131
- const value = kit.stringValue(checker, node.arguments[0]);
132
- const home = value === null ? null : decoratorsFile(value);
133
- if (value === null || !byName.has(value)) {
134
- report(file.rel, `${binding.name}() names ${value === null ? 'a connection that cannot be resolved to a constant' : `connection ${value}, which hfs.json does not declare`}; only the declared connections have an entity manager token.`, kit.at(file.rel, file.sourceFile, node));
135
- } else if (file.rel !== home) {
136
- report(file.rel, `${binding.name}(${value}) belongs in ${home} only; inject the shared manager with Inject${pascal(value)}EntityManager() instead of a second path to connection ${value}.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
137
- } else {
138
- perDecorators.set(value, (perDecorators.get(value) ?? 0) + 1);
139
- if (perDecorators.get(value) > 1) report(file.rel, `${home} resolves connection ${value} more than once; one connection has one injector.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
140
- }
141
- } else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) {
142
- const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || slotIs('be.app.cli') || slotIs('be.cli') || slotIs('be.tests.fixtures');
143
- if (!allowed) report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the cli; inject the shared EntityManager of the connection instead.`, kit.at(file.rel, file.sourceFile, node));
144
- }
145
- }
146
- const exportedName = (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) ? node.name.text
147
- : (ts.isFunctionDeclaration(node) && kit.isExported(node) && node.name ? node.name.text : null);
148
- if (exportedName && INJECTOR_NAME.test(exportedName)) {
149
- const home = [...byName.keys()].find(name => `Inject${pascal(name)}EntityManager` === exportedName);
150
- if (!home || file.rel !== decoratorsFile(home)) {
151
- report(file.rel, `${exportedName} is exported here, but the only injector of an entity manager is Inject<Conn>EntityManager in src/modules/platform/database/<conn>.decorators.ts, one per declared connection.`, kit.at(file.rel, file.sourceFile, node));
152
- }
153
- }
154
- return true;
155
- });
199
+ const checker = state.kit.checkerOf(file.sourceFile);
200
+ state.kit.walk(file.sourceFile, node => inspectManagerNode(file, checker, node, walkState));
156
201
  }
202
+ };
203
+
204
+ /** The receiver of a `<receiver>.register(...)` call that `node` is, or null. */
205
+ const registerReceiver = (ts, node) => {
206
+ if (!ts.isCallExpression(node)) return null;
207
+ const callee = node.expression;
208
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'register' ? callee.expression : null;
209
+ };
210
+
211
+ const isPropertyName = (ts, node) => (ts.isPropertyAssignment(node.parent ?? {}) || ts.isPropertyAccessExpression(node.parent ?? {})) && node.parent.name === node;
212
+
213
+ const passConnections = (argument, scope) => {
214
+ const { ts, kit, checker, byName, passed, root, app, report } = scope;
215
+ kit.walk(argument, inner => {
216
+ if (isPropertyName(ts, inner)) return true;
217
+ if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
218
+ const value = kit.stringValue(checker, inner);
219
+ if (value === null || !byName.has(value)) return true;
220
+ passed.set(value, (passed.get(value) ?? 0) + 1);
221
+ if (passed.get(value) > 1) report(root.rel, `App ${app.name} passes connection ${value} to the database module registration more than once; one connection is registered once per app.`, { ...kit.at(root.rel, root.sourceFile, inner), connection: value, app: app.name });
222
+ return false;
223
+ });
224
+ };
157
225
 
158
- // 3. Each connection is passed to the database module registration once per app.
226
+ const checkAppRegistrations = (config, kit, ts, byName, report) => {
159
227
  let registrations = 0;
160
228
  for (const app of config.apps) {
161
229
  const root = kit.appRoot(app.name);
162
230
  if (!root) continue;
163
231
  const checker = kit.checkerOf(root.sourceFile);
164
- const passed = new Map();
232
+ const scope = { ts, kit, checker, byName, passed: new Map(), root, app, report };
165
233
  kit.walk(root.sourceFile, node => {
166
- if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return true;
167
- const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
168
- if (!owner || owner.tier !== 'platform' || owner.name !== 'database') return true;
234
+ const receiver = registerReceiver(ts, node);
235
+ if (!receiver) return;
236
+ const owner = kit.declarationsOf(checker, receiver).map(kit.ownerOfDeclaration).find(Boolean);
237
+ if (owner?.tier !== 'platform' || owner.name !== 'database') return;
169
238
  registrations += 1;
170
- for (const argument of node.arguments) {
171
- kit.walk(argument, inner => {
172
- if (ts.isPropertyAssignment(inner.parent ?? {}) && inner.parent.name === inner) return true;
173
- if (ts.isPropertyAccessExpression(inner.parent ?? {}) && inner.parent.name === inner) return true;
174
- if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
175
- const value = kit.stringValue(checker, inner);
176
- if (value === null || !byName.has(value)) return true;
177
- passed.set(value, (passed.get(value) ?? 0) + 1);
178
- if (passed.get(value) > 1) report(root.rel, `App ${app.name} passes connection ${value} to the database module registration more than once; one connection is registered once per app.`, { ...kit.at(root.rel, root.sourceFile, inner), connection: value, app: app.name });
179
- return false;
180
- });
181
- }
182
- return true;
239
+ for (const argument of node.arguments) passConnections(argument, scope);
183
240
  });
184
241
  }
242
+ return registrations;
243
+ };
244
+
245
+ const listStackEnvs = stacksRoot => {
246
+ try { return fs.readdirSync(stacksRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort(byCodeUnit); } catch { return []; }
247
+ };
248
+
249
+ // The resolved database of one connection in one stack env, or null while its host, port or name is unset.
250
+ const resolveStackConnection = (kit, connection, values) => {
251
+ const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
252
+ const nameKey = configFile?.sourceFile.text.includes(`${connection.envPrefix}_DATABASE`) ? `${connection.envPrefix}_DATABASE` : `${connection.envPrefix}_NAME`;
253
+ const host = values.get(`${connection.envPrefix}_HOST`);
254
+ const port = values.get(`${connection.envPrefix}_PORT`);
255
+ const database = values.get(nameKey);
256
+ if (!host || !port || !database) return null;
257
+ const schema = connection.isolation === 'schema' ? (values.get(`${connection.envPrefix}_SCHEMA`) ?? '') : null;
258
+ return { host, port, database, triple: `${host.toLowerCase()}|${port}|${database}`, schema };
259
+ };
260
+
261
+ // True when the env resolves at least one connection; false when it is skipped.
262
+ const checkStackEnv = (config, kit, declared, report, stacksRoot, env) => {
263
+ // Relative to the side folder the machine judges (`../.starcistacks/...` for a side): the app-relative path once the side is prefixed.
264
+ const rel = path.relative(config.root, path.join(stacksRoot, env, 'runtime', 'env')).split(path.sep).join('/');
265
+ const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
266
+ if (!values || values.size === 0) return false;
267
+ const seen = new Map(); // host|port|database -> [{name, isolation, schema}]
268
+ let resolved = 0;
269
+ for (const connection of declared) {
270
+ const found = resolveStackConnection(kit, connection, values);
271
+ if (!found) continue;
272
+ resolved += 1;
273
+ const { host, port, database, triple, schema } = found;
274
+ const sharing = seen.get(triple) ?? [];
275
+ // Two contexts may share one database only as distinct, named schemas; a context that is its own database shares it with nobody.
276
+ const clash = sharing.find(other => schema === null || other.schema === null || other.schema === schema);
277
+ if (clash) {
278
+ const schemaDetail = schema !== null && clash.schema === schema ? `, schema ${schema || '(none)'}` : '';
279
+ report(rel, `Connections ${clash.name} and ${connection.name} resolve to the same database (${host}:${port}/${database}${schemaDetail}) in stack ${env}; contexts share a database only as distinct schemas (isolation schema, a different ${connection.envPrefix}_SCHEMA), otherwise one physical database is one connection.`, { connection: connection.name, env });
280
+ }
281
+ sharing.push({ name: connection.name, schema });
282
+ seen.set(triple, sharing);
283
+ }
284
+ return resolved > 0;
285
+ };
185
286
 
186
- // 4. Stacks: two connections that resolve to one host, port and database are one database.
287
+ const checkStackConnections = (config, kit, declared, report) => {
187
288
  let stacksChecked = 0;
188
289
  let stacksSkipped = 0;
189
290
  // .starcistacks sits at the app root: the side folder the machine judges reads its app's tree (locateDeclaration).
190
291
  const stacksRoot = path.join(locateDeclaration(config.root).appRoot, '.starcistacks');
191
- let envs = [];
192
- try { envs = fs.readdirSync(stacksRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort(); } catch { envs = []; }
193
- for (const env of envs) {
194
- // Relative to the side folder the machine judges (`../.starcistacks/...` for a side): the app-relative path once the side is prefixed.
195
- const rel = path.relative(config.root, path.join(stacksRoot, env, 'runtime', 'env')).split(path.sep).join('/');
196
- const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
197
- if (!values || values.size === 0) { stacksSkipped += 1; continue; }
198
- const seen = new Map(); // host|port|database -> [{name, isolation, schema}]
199
- let resolved = 0;
200
- for (const connection of declared) {
201
- const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
202
- const nameKey = configFile?.sourceFile.text.includes(`${connection.envPrefix}_DATABASE`) ? `${connection.envPrefix}_DATABASE` : `${connection.envPrefix}_NAME`;
203
- const host = values.get(`${connection.envPrefix}_HOST`);
204
- const port = values.get(`${connection.envPrefix}_PORT`);
205
- const database = values.get(nameKey);
206
- if (!host || !port || !database) continue;
207
- resolved += 1;
208
- const triple = `${host.toLowerCase()}|${port}|${database}`;
209
- const schema = connection.isolation === 'schema' ? (values.get(`${connection.envPrefix}_SCHEMA`) ?? '') : null;
210
- const sharing = seen.get(triple) ?? [];
211
- // Two contexts may share one database only as distinct, named schemas; a context that is its own database shares it with nobody.
212
- const clash = sharing.find(other => schema === null || other.schema === null || other.schema === schema);
213
- if (clash) {
214
- report(rel, `Connections ${clash.name} and ${connection.name} resolve to the same database (${host}:${port}/${database}${schema !== null && clash.schema === schema ? `, schema ${schema || '(none)'}` : ''}) in stack ${env}; contexts share a database only as distinct schemas (isolation schema, a different ${connection.envPrefix}_SCHEMA), otherwise one physical database is one connection.`, { connection: connection.name, env });
215
- }
216
- sharing.push({ name: connection.name, schema });
217
- seen.set(triple, sharing);
218
- }
219
- if (resolved) stacksChecked += 1; else stacksSkipped += 1;
292
+ for (const env of listStackEnvs(stacksRoot)) {
293
+ if (checkStackEnv(config, kit, declared, report, stacksRoot, env)) stacksChecked += 1; else stacksSkipped += 1;
220
294
  }
295
+ return { stacksChecked, stacksSkipped };
296
+ };
221
297
 
298
+ export function checkConnectionMap(input) {
299
+ const { config, graph } = input;
300
+ const kit = machineKit(input);
301
+ const { ts } = kit;
302
+ const declared = config.hfs.repo.connections ?? [];
303
+ const byName = new Map(declared.map(connection => [connection.name, connection]));
304
+ const violations = [];
305
+ const report = (rel, message, extra = {}) => violations.push({ ruleId: RULE, path: rel, message, ...extra });
306
+ const decoratorsFile = name => `${DATABASE_DIR}/${name}.decorators.ts`;
307
+ const databaseFiles = [...graph.files.values()].filter(file => file.rel.startsWith(`${DATABASE_DIR}/`));
308
+ const state = { config, graph, kit, ts, declared, byName, violations, report, decoratorsFile };
309
+ checkDeclaredConnections(declared, kit, ts, report, decoratorsFile);
310
+ reportUndeclaredDatabaseFiles(databaseFiles, byName, report);
311
+ checkManagerCalls(graph, state);
312
+ const registrations = checkAppRegistrations(config, kit, ts, byName, report);
313
+ const { stacksChecked, stacksSkipped } = checkStackConnections(config, kit, declared, report);
222
314
  return { violations, coverage: { status: 'checked', connections: declared.length, registrations, stacksChecked, stacksSkipped } };
223
315
  }
@@ -35,24 +35,30 @@ function tokenOfArgument(kit, checker, argument) {
35
35
  return { kind: 'unresolved', reason: `the token ${argument.text} does not resolve to a declaration`, node: argument };
36
36
  }
37
37
 
38
- /** The injector token of a custom `Inject<Thing>()` decorator, or an unresolved reason. */
39
- function tokenOfInjectDecorator(kit, checker, parameter, decorator) {
38
+ /** The first `injector(...)` call under a decorator declaration, or null. */
39
+ function injectorCallOf(kit, declaration) {
40
40
  const { ts } = kit;
41
- const head = decoratorCallee(ts, decorator);
42
- if (!head || !INJECT_NAME.test(head.callee.text)) return null;
43
- if (head.callee.text === 'Inject' && head.call?.arguments.length) return { ...tokenOfArgument(kit, checker, head.call.arguments[0]), decorator: head.callee.text, decoratorDeclaration: null, decoratorFile: null };
44
- const declaration = kit.declarationsOf(checker, head.callee).find(item => ts.isVariableDeclaration(item) || ts.isFunctionDeclaration(item));
45
- if (!declaration) return { kind: 'unresolved', reason: `${head.callee.text}() does not resolve to a declaration`, node: parameter, decorator: head.callee.text };
46
41
  let injectorCall = null;
47
42
  kit.walk(declaration, node => {
48
43
  if (injectorCall) return false;
49
44
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
50
- const declarationChecker = kit.checkerOf(declaration.getSourceFile());
51
- const binding = kit.importBinding(declarationChecker, node.expression);
45
+ const binding = kit.importBinding(kit.checkerOf(declaration.getSourceFile()), node.expression);
52
46
  if ((binding?.name ?? node.expression.text) === 'injector') injectorCall = node;
53
47
  }
54
48
  return true;
55
49
  });
50
+ return injectorCall;
51
+ }
52
+
53
+ /** The injector token of a custom `Inject<Thing>()` decorator, or an unresolved reason. */
54
+ function tokenOfInjectDecorator(kit, checker, parameter, decorator) {
55
+ const { ts } = kit;
56
+ const head = decoratorCallee(ts, decorator);
57
+ if (!head || !INJECT_NAME.test(head.callee.text)) return null;
58
+ if (head.callee.text === 'Inject' && head.call?.arguments.length) return { ...tokenOfArgument(kit, checker, head.call.arguments[0]), decorator: head.callee.text, decoratorDeclaration: null, decoratorFile: null };
59
+ const declaration = kit.declarationsOf(checker, head.callee).find(item => ts.isVariableDeclaration(item) || ts.isFunctionDeclaration(item));
60
+ if (!declaration) return { kind: 'unresolved', reason: `${head.callee.text}() does not resolve to a declaration`, node: parameter, decorator: head.callee.text };
61
+ const injectorCall = injectorCallOf(kit, declaration);
56
62
  if (!injectorCall) return { kind: 'unresolved', reason: `${head.callee.text}() is not built with injector<T>(TOKEN)`, node: parameter, decorator: head.callee.text };
57
63
  const declarationChecker = kit.checkerOf(declaration.getSourceFile());
58
64
  return { ...tokenOfArgument(kit, declarationChecker, injectorCall.arguments[0]), decorator: head.callee.text, decoratorDeclaration: declaration, injectorCall };
@@ -19,12 +19,83 @@ const RULE = 'BE_CONTEXT_COUPLING';
19
19
  const TYPEORM = 'typeorm';
20
20
  const RELATIONS = new Set(['OneToOne', 'OneToMany', 'ManyToOne', 'ManyToMany']);
21
21
  // The table identifier after the SQL keyword REFERENCES, optionally schema-qualified and quoted.
22
- const REFERENCES = /\bREFERENCES\s+(?:"?[A-Za-z0-9_]+"?\s*\.\s*)?"?([A-Za-z0-9_]+)"?/giu;
22
+ const REFERENCES = /\bREFERENCES\s+(?:"?\w+"?\s*\.\s*)?"?(\w+)"?/giu;
23
+
24
+ const checkCrossContextImports = (graph, model, covered, counts, once) => {
25
+ for (const edge of graph.edges) {
26
+ const from = graph.files.get(edge.from);
27
+ const to = graph.files.get(edge.to);
28
+ if (!from?.owner || !to?.owner || from.owner.root === to.owner.root) continue;
29
+ const fromContext = model.contextOfFile(edge.from);
30
+ const toContext = model.contextOfFile(edge.to);
31
+ if (!fromContext || !toContext || fromContext === toContext) continue;
32
+ counts.imports += 1;
33
+ if (covered.has(`${edge.from}|${to.owner.root}`)) continue;
34
+ once(`${edge.from}|${edge.to}|${edge.line}`, { path: edge.from, line: edge.line, column: edge.column, message: `${from.owner.root} (context ${fromContext}) imports ${to.owner.root} (context ${toContext}); a context never imports another context's entities, services or SQL. Keep a local copy fed by its events, or read a projection.`, context: fromContext, targetContext: toContext });
35
+ }
36
+ };
37
+
38
+ const relationTarget = (kit, model, argument) => {
39
+ const { ts } = kit;
40
+ const isFunction = argument && (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
41
+ const target = isFunction && !ts.isBlock(argument.body) ? model.unparen(argument.body) : null;
42
+ return target && (ts.isIdentifier(target) || ts.isPropertyAccessExpression(target)) ? target : null;
43
+ };
44
+
45
+ // The relation decorator (`@ManyToOne(() => Target)` imported from typeorm) a node is, with the checker that resolves it, else null.
46
+ const relationBinding = (kit, checker, node) => {
47
+ const { ts } = kit;
48
+ if (!ts.isDecorator(node) || !ts.isCallExpression(node.expression)) return null;
49
+ const binding = kit.importBinding(checker, node.expression.expression);
50
+ return binding?.module === TYPEORM && RELATIONS.has(binding.name) ? binding : null;
51
+ };
52
+
53
+ const checkRelations = (scan, file, ownContext) => {
54
+ const { kit, model, counts, covered, once } = scan;
55
+ const { ts } = kit;
56
+ const checker = kit.checkerOf(file.sourceFile);
57
+ kit.walk(file.sourceFile, node => {
58
+ const binding = relationBinding(kit, checker, node);
59
+ if (!binding) return;
60
+ counts.relations += 1;
61
+ const target = relationTarget(kit, model, node.expression.arguments[0]);
62
+ if (!target) return;
63
+ const home = kit.declarationsOf(checker, ts.isPropertyAccessExpression(target) ? target.name : target).map(kit.ownerOfDeclaration).find(Boolean);
64
+ const targetContext = home ? model.contextOfCapability(home.root) : null;
65
+ if (!home || !targetContext || targetContext === ownContext) return;
66
+ covered.add(`${file.rel}|${home.root}`);
67
+ once(`${file.rel}|${node.getStart()}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A ${binding.name} relation in ${file.owner.root} (context ${ownContext}) targets an entity of ${home.root} (context ${targetContext}); a relation never crosses a context. Keep the other context's id as a plain column and fill a local copy or a projection from its events.`, context: ownContext, targetContext });
68
+ });
69
+ };
70
+
71
+ const stringText = (ts, node) => (ts.isTemplateExpression(node) ? node.head.text + node.templateSpans.map(span => ` ${span.literal.text}`).join('') : node.text);
72
+
73
+ const checkForeignKeys = (scan, file, ownContext) => {
74
+ const { kit, model, tables, counts, once } = scan;
75
+ const { ts } = kit;
76
+ kit.walk(file.sourceFile, node => {
77
+ if (!(ts.isStringLiteralLike(node) || ts.isTemplateExpression(node))) return;
78
+ for (const match of stringText(ts, node).matchAll(REFERENCES)) {
79
+ counts.foreignKeys += 1;
80
+ const entity = tables.get(match[1].toLowerCase());
81
+ const targetContext = entity ? model.contextOfCapability(entity.owner) : null;
82
+ if (!entity || !targetContext || targetContext === ownContext) continue;
83
+ once(`${file.rel}|${node.getStart()}|${match[1]}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A foreign key in ${file.rel} references table ${entity.table} of ${entity.owner} (context ${targetContext}), but this migration belongs to context ${ownContext}; a foreign key never crosses a context. Keep the id as a plain column and validate it through the other context's API or a local copy fed by its events.`, context: ownContext, targetContext, table: entity.table });
84
+ }
85
+ });
86
+ };
87
+
88
+ const checkPersistenceFile = (scan, file) => {
89
+ if (file.slot !== 'be.persistence' || !file.owner) return;
90
+ const ownContext = scan.model.contextOfCapability(file.owner.root);
91
+ if (!ownContext) return;
92
+ if (path.posix.basename(file.rel).endsWith('.entity.ts')) checkRelations(scan, file, ownContext);
93
+ if (file.rel.slice(file.owner.root.length + 1).startsWith('persistence/migrations/')) checkForeignKeys(scan, file, ownContext);
94
+ };
23
95
 
24
96
  export function checkContextCoupling(input) {
25
97
  const { graph } = input;
26
98
  const kit = machineKit(input);
27
- const { ts, resolver } = kit;
28
99
  const model = contextModelOf(kit, graph);
29
100
  const { tables } = entitiesOf(kit, graph);
30
101
  const violations = [];
@@ -32,57 +103,10 @@ export function checkContextCoupling(input) {
32
103
  const once = (key, violation) => { if (!seen.has(key)) { seen.add(key); violations.push({ ruleId: RULE, ...violation }); } };
33
104
  const counts = { relations: 0, foreignKeys: 0, imports: 0 };
34
105
  const covered = new Set(); // `${fromRel}|${targetRoot}` pairs a relation already reported, so the import edge is not a second finding
106
+ const scan = { kit, model, tables, counts, covered, once };
35
107
 
36
- for (const file of graph.files.values()) {
37
- if (file.slot !== 'be.persistence' || !file.owner) continue;
38
- const base = path.posix.basename(file.rel);
39
- const ownContext = model.contextOfCapability(file.owner.root);
40
- if (!ownContext) continue;
41
- const checker = kit.checkerOf(file.sourceFile);
42
- if (base.endsWith('.entity.ts')) {
43
- kit.walk(file.sourceFile, node => {
44
- if (!ts.isDecorator(node) || !ts.isCallExpression(node.expression)) return true;
45
- const binding = kit.importBinding(checker, node.expression.expression);
46
- if (binding?.module !== TYPEORM || !RELATIONS.has(binding.name)) return true;
47
- counts.relations += 1;
48
- const argument = node.expression.arguments[0];
49
- const target = argument && (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) && !ts.isBlock(argument.body) ? model.unparen(argument.body) : null;
50
- if (!target || !(ts.isIdentifier(target) || ts.isPropertyAccessExpression(target))) return true;
51
- const home = kit.declarationsOf(checker, ts.isPropertyAccessExpression(target) ? target.name : target).map(kit.ownerOfDeclaration).find(Boolean);
52
- const targetContext = home ? model.contextOfCapability(home.root) : null;
53
- if (!home || !targetContext || targetContext === ownContext) return true;
54
- covered.add(`${file.rel}|${home.root}`);
55
- once(`${file.rel}|${node.getStart()}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A ${binding.name} relation in ${file.owner.root} (context ${ownContext}) targets an entity of ${home.root} (context ${targetContext}); a relation never crosses a context. Keep the other context's id as a plain column and fill a local copy or a projection from its events.`, context: ownContext, targetContext });
56
- return true;
57
- });
58
- }
59
- if (file.rel.slice(file.owner.root.length + 1).startsWith('persistence/migrations/')) {
60
- kit.walk(file.sourceFile, node => {
61
- if (!(ts.isStringLiteralLike(node) || ts.isTemplateExpression(node))) return true;
62
- const text = ts.isTemplateExpression(node) ? node.head.text + node.templateSpans.map(span => ` ${span.literal.text}`).join('') : node.text;
63
- for (const match of text.matchAll(REFERENCES)) {
64
- counts.foreignKeys += 1;
65
- const entity = tables.get(match[1].toLowerCase());
66
- const targetContext = entity ? model.contextOfCapability(entity.owner) : null;
67
- if (!entity || !targetContext || targetContext === ownContext) continue;
68
- once(`${file.rel}|${node.getStart()}|${match[1]}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A foreign key in ${file.rel} references table ${entity.table} of ${entity.owner} (context ${targetContext}), but this migration belongs to context ${ownContext}; a foreign key never crosses a context. Keep the id as a plain column and validate it through the other context's API or a local copy fed by its events.`, context: ownContext, targetContext, table: entity.table });
69
- }
70
- return true;
71
- });
72
- }
73
- }
108
+ for (const file of graph.files.values()) checkPersistenceFile(scan, file);
74
109
 
75
- for (const edge of graph.edges) {
76
- const from = graph.files.get(edge.from);
77
- const to = graph.files.get(edge.to);
78
- if (!from?.owner || !to?.owner || from.owner.root === to.owner.root) continue;
79
- const fromContext = model.contextOfFile(edge.from);
80
- const toContext = model.contextOfFile(edge.to);
81
- if (!fromContext || !toContext || fromContext === toContext) continue;
82
- counts.imports += 1;
83
- if (covered.has(`${edge.from}|${to.owner.root}`)) continue;
84
- once(`${edge.from}|${edge.to}|${edge.line}`, { path: edge.from, line: edge.line, column: edge.column, message: `${from.owner.root} (context ${fromContext}) imports ${to.owner.root} (context ${toContext}); a context never imports another context's entities, services or SQL. Keep a local copy fed by its events, or read a projection.`, context: fromContext, targetContext: toContext });
85
- }
86
- void resolver;
110
+ checkCrossContextImports(graph, model, covered, counts, once);
87
111
  return { violations, coverage: { status: 'checked', ...counts } };
88
112
  }