@starci/hfs 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +110 -12
  3. package/bin/hfs.mjs +119 -15
  4. package/emit/compiler.mjs +35 -0
  5. package/emit/contracts.mjs +97 -0
  6. package/emit/operations-worker.mjs +24 -0
  7. package/emit/operations.mjs +126 -0
  8. package/emit/schema-worker.mjs +117 -0
  9. package/emit/static-graph.mjs +670 -0
  10. package/emit/type-schema.mjs +145 -0
  11. package/package.json +4 -1
  12. package/report/sonar.mjs +180 -0
  13. package/runtime/engine/admission.mjs +284 -0
  14. package/runtime/engine/digest.mjs +10 -0
  15. package/runtime/engine/ledger-db.mjs +1245 -0
  16. package/runtime/engine/machine-db.mjs +1484 -0
  17. package/runtime/engine/migrations/machine/0001-init.sql +887 -0
  18. package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
  19. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
  20. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
  21. package/runtime/engine/plain-object.mjs +5 -0
  22. package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
  23. package/runtime/knowledge/hfs/slots.yaml +405 -137
  24. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  25. package/runtime/knowledge/sonar-gate.yaml +85 -0
  26. package/runtime/modules/kernel/failure-codes.yaml +1480 -16
  27. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  28. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  29. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  30. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  31. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  32. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  33. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  34. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  35. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  36. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  37. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  38. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  39. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  40. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  41. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  42. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  43. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  44. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  45. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  46. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  47. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  48. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  49. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  50. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  51. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  52. package/runtime/scripts/checks/architecture/index.mjs +316 -0
  53. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  54. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  55. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  56. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  57. package/runtime/scripts/checks/architecture/owners.mjs +89 -0
  58. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  59. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  60. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  61. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  62. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  63. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  64. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  65. package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
  66. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  67. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  68. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  69. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  70. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  71. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  72. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  73. package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
  74. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  75. package/runtime/scripts/checks/architecture.mjs +41 -0
  76. package/runtime/scripts/checks/common.mjs +37 -0
  77. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  78. package/runtime/scripts/lib/artifact-hold.mjs +89 -0
  79. package/runtime/scripts/lib/artifact-store.mjs +103 -0
  80. package/runtime/scripts/lib/fs-kind.mjs +10 -0
  81. package/runtime/scripts/lib/git.mjs +53 -0
  82. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  83. package/runtime/scripts/lib/hfs-check.mjs +254 -28
  84. package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
  85. package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
  86. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  87. package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
  88. package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
  89. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
  90. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
  91. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  92. package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
  93. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  94. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
  95. package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +88 -40
  98. package/runtime/scripts/lib/hfs-tree.mjs +80 -0
  99. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  100. package/runtime/scripts/lib/json.mjs +22 -0
  101. package/runtime/scripts/lib/language.mjs +107 -0
  102. package/runtime/scripts/lib/path-key.mjs +2 -0
  103. package/runtime/scripts/lib/redact.mjs +148 -0
  104. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  105. package/runtime/scripts/lib/safe-remove.mjs +179 -0
  106. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  107. package/runtime/scripts/lib/sleep-sync.mjs +17 -0
  108. package/runtime/scripts/lib/stack-declaration.mjs +52 -0
  109. package/runtime/scripts/lib/stack-services.mjs +217 -0
  110. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  111. package/scaffold/service.mjs +333 -0
  112. package/sync/format.mjs +46 -0
  113. package/sync/hygiene.mjs +56 -24
  114. package/sync/index.mjs +126 -41
  115. package/sync/managed.mjs +170 -0
  116. package/sync/skeleton.mjs +32 -10
  117. package/sync/sonar-key.mjs +13 -0
  118. package/sync/ts-strict.mjs +48 -0
  119. package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
  120. package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
  121. package/templates/be/hooks/husky/pre-commit +13 -0
  122. package/templates/be/hooks/husky/pre-push +7 -0
  123. package/templates/be/package-scripts/package.json +21 -0
  124. package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
  125. package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
  126. package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
  127. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  128. package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
  129. package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
  130. package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
  131. package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
  132. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
  133. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
  134. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
  135. package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
  136. package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
  137. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
  138. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
  139. package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
  140. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
  141. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
  142. package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
  143. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
  144. package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
  145. package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
  146. package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
  147. package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
  148. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
  149. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
  150. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
  151. package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
  152. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
  153. package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
  154. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
  155. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
  156. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
  157. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
  158. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
  159. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
  162. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
  163. package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
  164. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
  165. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
  166. package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
  167. package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
  168. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
  169. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
  170. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
  171. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
  172. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
  173. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
  174. package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
  175. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
  176. package/templates/be/tool-config/eslint.config.mjs +3 -0
  177. package/templates/be/tool-config/jest.config.js +1 -0
  178. package/templates/be/tool-config/prettierignore +8 -0
  179. package/templates/be/tool-config/prettierrc +1 -0
  180. package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
  181. package/templates/be/tool-config/tsconfig.build.json +5 -0
  182. package/templates/be/tool-config/tsconfig.json +11 -0
  183. package/templates/common/gitignore.base +1 -1
  184. package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
  185. package/templates/fe/hooks/husky/pre-commit +16 -0
  186. package/templates/fe/hooks/husky/pre-push +6 -0
  187. package/templates/fe/package-scripts/package.json +17 -0
  188. package/templates/fe/parts/api-client.ts +44 -0
  189. package/templates/fe/parts/api-outcome.ts +7 -0
  190. package/templates/fe/quality-config/sonar-project.properties +8 -0
  191. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
  192. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
  193. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
  194. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  195. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
  196. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
  197. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
  198. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
  199. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
  200. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
  201. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
  202. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
  203. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
  204. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
  205. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
  206. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
  207. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
  208. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
  209. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
  210. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
  211. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
  212. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
  213. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
  214. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
  215. package/templates/fe/tool-config/eslint.config.mjs +3 -0
  216. package/templates/fe/tool-config/prettierignore +10 -0
  217. package/templates/fe/tool-config/prettierrc +1 -0
  218. package/templates/fe/tool-config/stylelint.config.mjs +3 -0
  219. package/templates/fe/tool-config/tsconfig.json +4 -0
  220. package/templates/be/pre-commit +0 -8
  221. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
  222. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
  223. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
  224. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
  225. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
  226. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
  227. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
  228. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
  229. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
  230. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
  231. package/templates/common/codecov.yml +0 -13
  232. package/templates/common/pre-push +0 -5
  233. package/templates/fe/e2e.yml +0 -22
  234. package/templates/fe/pre-commit +0 -7
  235. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
  236. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
  237. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
  238. package/templates/fe/sonar-project.properties +0 -11
  239. /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
  240. /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
  241. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
  242. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
  243. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
  244. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
  245. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
  246. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
@@ -0,0 +1,208 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { machineKit, pascal, upperSnake } from './machine-ast.mjs';
4
+
5
+ /**
6
+ * R84 `connection-map` (BE_CONNECTION_DUPLICATE), folding RED01 and RED02. One physical database is one connection, one
7
+ * `Inject<Conn>EntityManager()` injector, one config; the `hfs.json` connections, the connection files, the injectors, the
8
+ * database module registration of every app and the env of every stack correspond one to one.
9
+ *
10
+ * - per declared connection `<name>`: `src/modules/platform/database/<name>.connection.ts` exports
11
+ * `<NAME>_CONNECTION = "<name>"`, `<name>.decorators.ts` exports `Inject<Pascal>EntityManager`, `<name>.config.ts`
12
+ * exists and reads only `<ENVPREFIX>_*` keys;
13
+ * - a connection file for a name hfs.json does not declare is refused;
14
+ * - `getEntityManagerToken(` and `InjectEntityManager(` (from @nestjs/typeorm) resolve their argument through the
15
+ * checker to a connection name and may be called only in that connection's decorators file, once; the DataSource
16
+ * forms only in platform/database, the migrate app and the test fixtures; an exported `Inject*EntityManager`
17
+ * anywhere else (a capability-specific injector, a duplicate) is refused;
18
+ * - inside one app every connection is passed to the platform/database module registration once;
19
+ * - in every `.starcistacks/<env>/runtime/env` two connections whose `<PREFIX>_HOST`, `<PREFIX>_PORT` and
20
+ * `<PREFIX>_NAME` (or `_DATABASE`, whichever the config reads) resolve to one triple are the same database.
21
+ */
22
+ export const CONNECTION_RULE_IDS = ['BE_CONNECTION_DUPLICATE'];
23
+
24
+ const RULE = 'BE_CONNECTION_DUPLICATE';
25
+ export const DATABASE_DIR = 'src/modules/platform/database';
26
+ const TYPEORM = '@nestjs/typeorm';
27
+ const MANAGER_CALLS = new Set(['getEntityManagerToken', 'InjectEntityManager']);
28
+ const SOURCE_CALLS = new Set(['getDataSourceToken', 'InjectDataSource']);
29
+ const ENV_KEY = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u;
30
+ const INJECTOR_NAME = /^Inject[A-Za-z0-9]*EntityManager$/u;
31
+
32
+ function readEnvFiles(target) {
33
+ const merged = new Map();
34
+ const readFile = file => {
35
+ let text;
36
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
37
+ for (const raw of text.split(/\r?\n/u)) {
38
+ const line = raw.trim();
39
+ if (!line || line.startsWith('#')) continue;
40
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
41
+ if (!match) continue;
42
+ let value = match[2].trim();
43
+ if (/^(['"]).*\1$/u.test(value)) value = value.slice(1, -1);
44
+ merged.set(match[1], value.trim());
45
+ }
46
+ };
47
+ let stat;
48
+ try { stat = fs.statSync(target); } catch { return null; }
49
+ if (stat.isFile()) readFile(target);
50
+ else for (const entry of fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
51
+ if (entry.isFile() && !entry.name.toLowerCase().endsWith('.md')) readFile(path.join(target, entry.name));
52
+ }
53
+ return merged;
54
+ }
55
+
56
+ export function checkConnectionMap(input) {
57
+ const { config, graph } = input;
58
+ const kit = machineKit(input);
59
+ const { ts } = kit;
60
+ const declared = config.hfs.repo.connections ?? [];
61
+ const byName = new Map(declared.map(connection => [connection.name, connection]));
62
+ const violations = [];
63
+ const report = (rel, message, extra = {}) => violations.push({ ruleId: RULE, path: rel, message, ...extra });
64
+ const decoratorsFile = name => `${DATABASE_DIR}/${name}.decorators.ts`;
65
+ const databaseFiles = [...graph.files.values()].filter(file => file.rel.startsWith(`${DATABASE_DIR}/`));
66
+
67
+ // 1. The three files of each declared connection, and no file of an undeclared one.
68
+ for (const connection of declared) {
69
+ const connectionRel = `${DATABASE_DIR}/${connection.name}.connection.ts`;
70
+ const constant = `${upperSnake(connection.name)}_CONNECTION`;
71
+ const connectionFile = kit.graphFile(connectionRel);
72
+ 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 });
73
+ else {
74
+ const checker = kit.checkerOf(connectionFile.sourceFile);
75
+ const statement = connectionFile.sourceFile.statements.find(item => ts.isVariableStatement(item) && kit.isExported(item)
76
+ && item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === constant));
77
+ const initializer = statement?.declarationList.declarations.find(declaration => declaration.name.text === constant)?.initializer;
78
+ if (!statement || kit.stringValue(checker, initializer) !== connection.name) {
79
+ report(connectionRel, `${connectionRel} must export \`${constant} = "${connection.name}"\` (the connection name declared in hfs.json).`, { connection: connection.name });
80
+ }
81
+ }
82
+ const decorators = kit.graphFile(decoratorsFile(connection.name));
83
+ const injector = `Inject${pascal(connection.name)}EntityManager`;
84
+ const exported = decorators?.sourceFile.statements.some(item => (ts.isVariableStatement(item) && kit.isExported(item)
85
+ && item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === injector))
86
+ || (ts.isFunctionDeclaration(item) && kit.isExported(item) && item.name?.text === injector));
87
+ 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 });
88
+ const configRel = `${DATABASE_DIR}/${connection.name}.config.ts`;
89
+ const configFile = kit.graphFile(configRel);
90
+ if (!configFile) {
91
+ report(configRel, `Connection ${connection.name} needs ${configRel}, the one place its ${connection.envPrefix}_* keys are read.`, { connection: connection.name });
92
+ continue;
93
+ }
94
+ kit.walk(configFile.sourceFile, node => {
95
+ let key = null;
96
+ if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) key = node.text;
97
+ else if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
98
+ && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process' && ENV_KEY.test(node.name.text)) key = node.name.text;
99
+ if (key === null) return true;
100
+ const own = key.startsWith(`${connection.envPrefix}_`);
101
+ const foreign = declared.find(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
102
+ if (!own || foreign) {
103
+ 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 });
104
+ }
105
+ return false;
106
+ });
107
+ }
108
+ for (const file of databaseFiles) {
109
+ const match = /^(.+)\.(connection|decorators|config)\.ts$/u.exec(path.posix.basename(file.rel));
110
+ if (match && path.posix.dirname(file.rel) === DATABASE_DIR && !byName.has(match[1])) {
111
+ 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] });
112
+ }
113
+ }
114
+
115
+ // 2. Every entity manager token and injector in the program: one home per connection.
116
+ const perDecorators = new Map();
117
+ for (const file of graph.files.values()) {
118
+ const checker = kit.checkerOf(file.sourceFile);
119
+ const slotIs = id => file.slot === id;
120
+ kit.walk(file.sourceFile, node => {
121
+ if (ts.isCallExpression(node)) {
122
+ const binding = kit.importBinding(checker, node.expression);
123
+ if (binding?.module === TYPEORM && MANAGER_CALLS.has(binding.name)) {
124
+ const value = kit.stringValue(checker, node.arguments[0]);
125
+ const home = value === null ? null : decoratorsFile(value);
126
+ if (value === null || !byName.has(value)) {
127
+ 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));
128
+ } else if (file.rel !== home) {
129
+ 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 });
130
+ } else {
131
+ perDecorators.set(value, (perDecorators.get(value) ?? 0) + 1);
132
+ 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 });
133
+ }
134
+ } else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) {
135
+ const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || slotIs('be.app.migrate') || slotIs('be.tests.fixtures');
136
+ if (!allowed) report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the migrate app; inject the shared EntityManager of the connection instead.`, kit.at(file.rel, file.sourceFile, node));
137
+ }
138
+ }
139
+ const exportedName = (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) ? node.name.text
140
+ : (ts.isFunctionDeclaration(node) && kit.isExported(node) && node.name ? node.name.text : null);
141
+ if (exportedName && INJECTOR_NAME.test(exportedName)) {
142
+ const home = [...byName.keys()].find(name => `Inject${pascal(name)}EntityManager` === exportedName);
143
+ if (!home || file.rel !== decoratorsFile(home)) {
144
+ 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));
145
+ }
146
+ }
147
+ return true;
148
+ });
149
+ }
150
+
151
+ // 3. Each connection is passed to the database module registration once per app.
152
+ let registrations = 0;
153
+ for (const app of config.apps) {
154
+ const root = kit.appRoot(app.name);
155
+ if (!root) continue;
156
+ const checker = kit.checkerOf(root.sourceFile);
157
+ const passed = new Map();
158
+ kit.walk(root.sourceFile, node => {
159
+ if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return true;
160
+ const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
161
+ if (!owner || owner.tier !== 'platform' || owner.name !== 'database') return true;
162
+ registrations += 1;
163
+ for (const argument of node.arguments) {
164
+ kit.walk(argument, inner => {
165
+ if (ts.isPropertyAssignment(inner.parent ?? {}) && inner.parent.name === inner) return true;
166
+ if (ts.isPropertyAccessExpression(inner.parent ?? {}) && inner.parent.name === inner) return true;
167
+ if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
168
+ const value = kit.stringValue(checker, inner);
169
+ if (value === null || !byName.has(value)) return true;
170
+ passed.set(value, (passed.get(value) ?? 0) + 1);
171
+ 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 });
172
+ return false;
173
+ });
174
+ }
175
+ return true;
176
+ });
177
+ }
178
+
179
+ // 4. Stacks: two connections that resolve to one host, port and database are one database.
180
+ let stacksChecked = 0;
181
+ let stacksSkipped = 0;
182
+ const stacksRoot = path.join(config.root, '.starcistacks');
183
+ let envs = [];
184
+ try { envs = fs.readdirSync(stacksRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort(); } catch { envs = []; }
185
+ for (const env of envs) {
186
+ const rel = `.starcistacks/${env}/runtime/env`;
187
+ const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
188
+ if (!values || values.size === 0) { stacksSkipped += 1; continue; }
189
+ const seen = new Map();
190
+ let resolved = 0;
191
+ for (const connection of declared) {
192
+ const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
193
+ const nameKey = configFile?.sourceFile.text.includes(`${connection.envPrefix}_DATABASE`) ? `${connection.envPrefix}_DATABASE` : `${connection.envPrefix}_NAME`;
194
+ const host = values.get(`${connection.envPrefix}_HOST`);
195
+ const port = values.get(`${connection.envPrefix}_PORT`);
196
+ const database = values.get(nameKey);
197
+ if (!host || !port || !database) continue;
198
+ resolved += 1;
199
+ const triple = `${host.toLowerCase()}|${port}|${database}`;
200
+ if (seen.has(triple)) {
201
+ report(rel, `Connections ${seen.get(triple)} and ${connection.name} resolve to the same database (${host}:${port}/${database}) in stack ${env}; one physical database is one connection.`, { connection: connection.name, env });
202
+ } else seen.set(triple, connection.name);
203
+ }
204
+ if (resolved) stacksChecked += 1; else stacksSkipped += 1;
205
+ }
206
+
207
+ return { violations, coverage: { status: 'checked', connections: declared.length, registrations, stacksChecked, stacksSkipped } };
208
+ }
@@ -0,0 +1,100 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * The constructor dependencies of a service, read the way the injection law (R85) writes them, shared by the unit-spec
5
+ * provider check (R48 `unit-spec-providers`) and the token export check (R85 `injection-token-exported`).
6
+ *
7
+ * A constructor parameter is one of
8
+ * - `token`: decorated with a custom `Inject<Thing>()` decorator. The decorator is declared in a `<cap>.decorators.ts`
9
+ * as `injector<T>(TOKEN)`; the dependency is the TOKEN it names, identified by the name the token is exported under
10
+ * (the declaration's own name), never by the decorator's or the parameter's name. A raw `@Inject(TOKEN)` is read the same way.
11
+ * - `class`: no injection decorator and a class type; the dependency is that class.
12
+ * - `unresolved`: anything else (a token expression that is not a plain identifier, a decorator the reader cannot follow,
13
+ * a type that is not a class).
14
+ */
15
+ const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/u;
16
+
17
+ /** The identifier a decorator names: `@InjectFoo()` -> InjectFoo, `@Inject(TOKEN)` -> Inject; null for a non-identifier decorator. */
18
+ function decoratorCallee(ts, decorator) {
19
+ const expression = decorator.expression;
20
+ const callee = ts.isCallExpression(expression) ? expression.expression : expression;
21
+ return ts.isIdentifier(callee) ? { callee, call: ts.isCallExpression(expression) ? expression : null } : null;
22
+ }
23
+
24
+ /** The token a `injector(<arg>)` call names, judged where the argument is declared. */
25
+ function tokenOfArgument(kit, checker, argument) {
26
+ const { ts } = kit;
27
+ if (!argument || !ts.isIdentifier(argument)) return { kind: 'unresolved', reason: 'its token is not a plain identifier of an exported const', node: argument ?? null };
28
+ const declaration = kit.declarationsOf(checker, argument).find(item => ts.isVariableDeclaration(item) || ts.isBindingElement(item));
29
+ if (declaration) {
30
+ const name = ts.isIdentifier(declaration.name) ? declaration.name.text : argument.text;
31
+ return { kind: 'token', name, declaration, argument, external: false };
32
+ }
33
+ const binding = kit.importBinding(checker, argument);
34
+ if (binding && !binding.module.startsWith('.')) return { kind: 'token', name: binding.name, declaration: null, argument, external: true };
35
+ return { kind: 'unresolved', reason: `the token ${argument.text} does not resolve to a declaration`, node: argument };
36
+ }
37
+
38
+ /** The injector token of a custom `Inject<Thing>()` decorator, or an unresolved reason. */
39
+ function tokenOfInjectDecorator(kit, checker, parameter, decorator) {
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
+ let injectorCall = null;
47
+ kit.walk(declaration, node => {
48
+ if (injectorCall) return false;
49
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
50
+ const declarationChecker = kit.checkerOf(declaration.getSourceFile());
51
+ const binding = kit.importBinding(declarationChecker, node.expression);
52
+ if ((binding?.name ?? node.expression.text) === 'injector') injectorCall = node;
53
+ }
54
+ return true;
55
+ });
56
+ if (!injectorCall) return { kind: 'unresolved', reason: `${head.callee.text}() is not built with injector<T>(TOKEN)`, node: parameter, decorator: head.callee.text };
57
+ const declarationChecker = kit.checkerOf(declaration.getSourceFile());
58
+ return { ...tokenOfArgument(kit, declarationChecker, injectorCall.arguments[0]), decorator: head.callee.text, decoratorDeclaration: declaration, injectorCall };
59
+ }
60
+
61
+ /** The class a parameter type names, judged where the type is declared; null when it is not a class type. */
62
+ function classOfType(kit, checker, type) {
63
+ const { ts } = kit;
64
+ if (!type || !ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName)) return null;
65
+ const declarations = kit.declarationsOf(checker, type.typeName);
66
+ const declared = declarations.find(item => ts.isClassDeclaration(item) && item.name);
67
+ if (declared) return declared.name.text;
68
+ if (declarations.length) return null;
69
+ const binding = kit.importBinding(checker, type.typeName);
70
+ return binding && binding.name !== 'default' && binding.name !== '*' ? binding.name : null;
71
+ }
72
+
73
+ /** The dependencies of one class, in constructor order: [{kind, name?, node, parameter, ...}]. */
74
+ export function constructorDependencies(kit, file, classDeclaration) {
75
+ const { ts } = kit;
76
+ const checker = kit.checkerOf(file.sourceFile);
77
+ const constructor = classDeclaration.members.find(member => ts.isConstructorDeclaration(member) && member.body !== undefined)
78
+ ?? classDeclaration.members.find(member => ts.isConstructorDeclaration(member));
79
+ if (!constructor) return [];
80
+ return constructor.parameters.map(parameter => {
81
+ for (const decorator of kit.decorators(parameter)) {
82
+ const token = tokenOfInjectDecorator(kit, checker, parameter, decorator);
83
+ if (token) return { ...token, parameter, node: token.node ?? parameter };
84
+ }
85
+ const name = classOfType(kit, checker, parameter.type);
86
+ if (name) return { kind: 'class', name, parameter, node: parameter };
87
+ return { kind: 'unresolved', reason: 'it has no Inject<Thing>() decorator and its type is not a class', parameter, node: parameter };
88
+ });
89
+ }
90
+
91
+ /** Every service class of the program: [{file, declaration}] for each class of a `*.service.ts` file. */
92
+ export function serviceClasses(kit, graph) {
93
+ const { ts } = kit;
94
+ const found = [];
95
+ for (const file of graph.files.values()) {
96
+ if (!path.posix.basename(file.rel).endsWith('.service.ts')) continue;
97
+ for (const statement of file.sourceFile.statements) if (ts.isClassDeclaration(statement) && statement.name) found.push({ file, declaration: statement });
98
+ }
99
+ return found;
100
+ }
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { treeOf } from './required-files.mjs';
4
+ import { allowsFile } from '../../lib/hfs-allows.mjs';
5
+
6
+ /**
7
+ * R47 `contract-fixture-guard` (BE_CONTRACT_UNGUARDED). A fake at the network edge serves payload fixtures; a fixture drifts
8
+ * from the real provider silently unless a sandbox contract spec compares them. So every `fakes/<provider>/` of the test
9
+ * world that holds payload fixtures (`payloads/*.json`) needs at least one `src/tests/contract/<provider>/*.contract-spec.ts`
10
+ * (slot be.tests.contract) that, in its AST,
11
+ * 1. references that provider's fixtures: a call whose argument is the provider's name as a string literal (the world's
12
+ * fixture accessor, `renderPayload("<provider>", ...)`), an import from `fakes/<provider>/`, or a string that names
13
+ * `fakes/<provider>/`; and
14
+ * 2. compares shapes: an `expect(<... shapeOf(real) ...>).toEqual(<... shapeOf(fixture) ...>)` (or `toStrictEqual`), where
15
+ * `shapeOf` is the helper the world vocabulary names (knowledge/hfs/slots.yaml ruleParams.be.contractShape.helper) and
16
+ * is imported, never declared in the spec.
17
+ * A fake with fixtures and no such spec is a finding at its first fixture. The tree is the tracked one over the world's own
18
+ * slot; nothing names a path. Fakes the test-world library provides (declared in `test-world.config.ts`, held in the library
19
+ * package) have no tracked fixtures here and are guarded by the library's own contract specs, so only the repository's own
20
+ * `fakes/<provider>/` folders are judged.
21
+ */
22
+ export const CONTRACT_FIXTURE_GUARD_RULE_IDS = ['BE_CONTRACT_UNGUARDED'];
23
+
24
+ const RULE = 'BE_CONTRACT_UNGUARDED';
25
+ const WORLD_SLOT = 'be.tests.world';
26
+ const CONTRACT_SLOT = 'be.tests.contract';
27
+ const FAKES_DIRECTORY = 'fakes/';
28
+ const PAYLOADS = /^([^/]+)\/payloads\/[^/]+\.json$/u;
29
+ const SHAPE_MATCHERS = new Set(['toEqual', 'toStrictEqual']);
30
+
31
+ const unwrap = (ts, node) => {
32
+ let current = node;
33
+ while (current && (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current) || ts.isAwaitExpression(current))) current = current.expression;
34
+ return current;
35
+ };
36
+
37
+ /** Every node under `root`, itself included. */
38
+ function walk(ts, root, visit) {
39
+ const step = node => { visit(node); ts.forEachChild(node, step); };
40
+ step(root);
41
+ }
42
+
43
+ /** Facts one contract spec establishes: the providers whose fixtures it references and whether it compares shapes. */
44
+ function readSpec(ts, sourceFile, helper, providers) {
45
+ const shapeNames = new Set();
46
+ const referenced = new Set();
47
+ for (const statement of sourceFile.statements) {
48
+ if (!ts.isImportDeclaration(statement)) continue;
49
+ const from = statement.moduleSpecifier.text;
50
+ for (const provider of providers) if (from.includes(`${FAKES_DIRECTORY}${provider}/`) || from.endsWith(`${FAKES_DIRECTORY}${provider}`)) referenced.add(provider);
51
+ const named = statement.importClause?.namedBindings;
52
+ if (!named || !ts.isNamedImports(named)) continue;
53
+ for (const element of named.elements) if ((element.propertyName ?? element.name).text === helper) shapeNames.add(element.name.text);
54
+ }
55
+ const callsShape = node => {
56
+ let found = false;
57
+ walk(ts, node, inner => { if (ts.isCallExpression(inner) && ts.isIdentifier(inner.expression) && shapeNames.has(inner.expression.text)) found = true; });
58
+ return found;
59
+ };
60
+ let compares = false;
61
+ walk(ts, sourceFile, node => {
62
+ if (ts.isStringLiteralLike(node)) for (const provider of providers) if (node.text.includes(`${FAKES_DIRECTORY}${provider}/`)) referenced.add(provider);
63
+ if (!ts.isCallExpression(node)) return;
64
+ const named = ts.isIdentifier(node.expression) ? node.expression.text : null;
65
+ for (const argument of node.arguments) {
66
+ const value = unwrap(ts, argument);
67
+ if (value && ts.isStringLiteralLike(value) && providers.has(value.text) && named !== 'describe' && named !== 'it' && named !== 'test') referenced.add(value.text);
68
+ }
69
+ const callee = node.expression;
70
+ if (!ts.isPropertyAccessExpression(callee) || !SHAPE_MATCHERS.has(callee.name.text)) return;
71
+ let receiver = unwrap(ts, callee.expression);
72
+ while (receiver && ts.isPropertyAccessExpression(receiver)) receiver = unwrap(ts, receiver.expression); // .not / .resolves
73
+ if (!receiver || !ts.isCallExpression(receiver) || !ts.isIdentifier(receiver.expression) || receiver.expression.text !== 'expect') return;
74
+ const [actual] = receiver.arguments;
75
+ const [expected] = node.arguments;
76
+ if (actual && expected && callsShape(actual) && callsShape(expected)) compares = true;
77
+ });
78
+ return { referenced, compares };
79
+ }
80
+
81
+ export function checkContractFixtureGuard(input) {
82
+ const { config, graph, context } = input;
83
+ const ts = context.ts;
84
+ const resolver = graph.resolver;
85
+ const { contractShape } = resolver.ruleParams();
86
+ const tree = treeOf(config.root);
87
+ const violations = [];
88
+ const fixtures = new Map(); // provider -> first payload fixture file
89
+ const specs = new Map(); // provider -> [contract spec file]
90
+ for (const file of [...tree.files].sort()) {
91
+ const classified = resolver.classifyPath(file);
92
+ if (classified.status !== 'owned') continue;
93
+ if (classified.slot === CONTRACT_SLOT) {
94
+ const provider = classified.bindings?.provider;
95
+ if (provider && file.endsWith('.contract-spec.ts')) specs.set(provider, [...(specs.get(provider) ?? []), file]);
96
+ continue;
97
+ }
98
+ const verdict = classified.slot === WORLD_SLOT ? allowsFile(resolver, file) : null;
99
+ if (verdict?.slot === WORLD_SLOT) {
100
+ if (!verdict.relative.startsWith(FAKES_DIRECTORY)) continue;
101
+ const match = PAYLOADS.exec(verdict.relative.slice(FAKES_DIRECTORY.length));
102
+ if (match && !fixtures.has(match[1])) fixtures.set(match[1], file);
103
+ }
104
+ }
105
+ const providers = new Set(fixtures.keys());
106
+ const guarded = new Set();
107
+ for (const [provider, files] of specs) {
108
+ for (const file of files) {
109
+ const absolute = path.join(config.root, ...file.split('/'));
110
+ if (!fs.existsSync(absolute)) continue;
111
+ const sourceFile = ts.createSourceFile(absolute, fs.readFileSync(absolute, 'utf8'), ts.ScriptTarget.Latest, true); // the contract tree is outside the root program
112
+ const facts = readSpec(ts, sourceFile, contractShape.helper, providers);
113
+ if (facts.compares && facts.referenced.has(provider)) guarded.add(provider);
114
+ }
115
+ }
116
+ for (const [provider, file] of [...fixtures].sort(([a], [b]) => a.localeCompare(b))) {
117
+ if (guarded.has(provider)) continue;
118
+ const has = specs.get(provider)?.length ?? 0;
119
+ const why = has === 0
120
+ ? `there is no src/tests/contract/${provider}/*.contract-spec.ts`
121
+ : `none of the ${has} contract spec(s) under src/tests/contract/${provider}/ both references the ${provider} fixtures and asserts expect(${contractShape.helper}(real)).toEqual(${contractShape.helper}(fixture))`;
122
+ violations.push({
123
+ ruleId: RULE, path: file, line: 1, column: 1, slot: WORLD_SLOT,
124
+ message: `fakes/${provider}/ serves payload fixtures but no contract spec guards them: ${why}. A fixture that is not compared with the real provider drifts silently; add the sandbox contract spec (the contract helper of the world skips it when the sandbox config is absent).`,
125
+ });
126
+ }
127
+ return { violations, coverage: { status: 'checked', fixtureFakes: fixtures.size, contractSpecs: [...specs.values()].flat().length } };
128
+ }