@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,163 @@
1
+ import { treeOf } from './required-files.mjs';
2
+ import { allowsFile } from '../../lib/hfs-allows.mjs';
3
+ import { DEFAULT_ENVIRONMENT, STACKS_DIRECTORY, STATEFUL_KINDS, namesOfService, readStack } from '../../lib/stack-services.mjs';
4
+
5
+ /**
6
+ * R47 `test-world-files` (BE_TEST_TOPOLOGY). Two judgements over the test world, read through slots, the repository's own
7
+ * stack definition (`.starcistacks/<env>`, scripts/lib/stack-services.mjs) and the world's declaration
8
+ * (`test-world.config.ts` of the test-world library), never through a path or a name list:
9
+ *
10
+ * 1. Files. `src/tests/world/` is the only test infrastructure location, and it holds only what its slot `allows`
11
+ * (knowledge/hfs/slots.yaml be.tests.world: global-setup.ts, global-teardown.ts, use-test-world.ts, and fakes/; kit/ is
12
+ * its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
13
+ * (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
14
+ * matched by the slot's own entries through `allowsFile`.
15
+ * 2. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world;
16
+ * `fakes/<provider>/` holds a network-edge fake of an external SaaS the team does not operate. A `fakes/<provider>/` that
17
+ * fakes a stack service (its own name, its image repository or a well-known alias of the image) is refused, except the one
18
+ * owner-approved exception: a stack service that is stateless compute needing special hardware or an external model
19
+ * (GPU inference, a self-hosted embedding model) and that the config declares in `fakedBy`:
20
+ * `fakedBy: { "<stack service>": { fake: "<fakes/ folder>", reason: "<non-empty>" } }`. A stack service of a stateful
21
+ * kind (database, cache, identity, storage, mail, queue, search) or with a persistent volume is never accepted, and a
22
+ * `fakedBy` entry that names a stack service or a fake folder that does not exist, or has no reason, is refused as stale
23
+ * or empty. The config's `stacks` names the stack environments the world runs (default `dev`); the machine reads the
24
+ * literal object of the config through the TypeScript AST and nothing else of it.
25
+ */
26
+ export const TEST_WORLD_FILES_RULE_IDS = ['BE_TEST_TOPOLOGY'];
27
+
28
+ const RULE = 'BE_TEST_TOPOLOGY';
29
+ const WORLD_SLOT = 'be.tests.world';
30
+ const FAKES_DIRECTORY = 'fakes/';
31
+ const CONFIG_FILE = 'test-world.config.ts';
32
+ const defaultEnvironment = DEFAULT_ENVIRONMENT;
33
+ const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
34
+
35
+ const nameOf = (ts, name) => (name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) ? name.text : null);
36
+ const unwrap = (ts, node) => {
37
+ let current = node;
38
+ while (current && (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression?.(current))) current = current.expression;
39
+ return current;
40
+ };
41
+ const propertyOf = (ts, literal, key) => literal.properties.find(property => ts.isPropertyAssignment(property) && nameOf(ts, property.name) === key)?.initializer ?? null;
42
+
43
+ /** The object literal a config file declares: `export default { ... }` or `export default defineTestWorld({ ... })`. */
44
+ function configLiteral(ts, sourceFile) {
45
+ for (const statement of sourceFile.statements) {
46
+ if (!ts.isExportAssignment(statement)) continue;
47
+ const expression = unwrap(ts, statement.expression);
48
+ if (ts.isObjectLiteralExpression(expression)) return expression;
49
+ if (ts.isCallExpression(expression)) {
50
+ const [first] = expression.arguments;
51
+ const literal = first ? unwrap(ts, first) : null;
52
+ if (literal && ts.isObjectLiteralExpression(literal)) return literal;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** `stacks` as a list of environment names: an array of strings, or the keys of an object literal. */
59
+ function stacksOf(ts, literal) {
60
+ const node = literal ? propertyOf(ts, literal, 'stacks') : null;
61
+ const value = node ? unwrap(ts, node) : null;
62
+ if (!value) return null;
63
+ if (ts.isArrayLiteralExpression(value)) return value.elements.filter(element => ts.isStringLiteralLike(element)).map(element => element.text);
64
+ if (ts.isObjectLiteralExpression(value)) return value.properties.map(property => nameOf(ts, property.name)).filter(Boolean);
65
+ return null;
66
+ }
67
+
68
+ /** `fakedBy` entries: [{service, fake, reason, line, column}]; a value the reader cannot read statically has an empty fake and reason. */
69
+ function fakedByOf(ts, sourceFile, literal) {
70
+ const node = literal ? propertyOf(ts, literal, 'fakedBy') : null;
71
+ const value = node ? unwrap(ts, node) : null;
72
+ if (!value || !ts.isObjectLiteralExpression(value)) return [];
73
+ const entries = [];
74
+ for (const property of value.properties) {
75
+ if (!ts.isPropertyAssignment(property)) continue;
76
+ const service = nameOf(ts, property.name);
77
+ const body = unwrap(ts, property.initializer);
78
+ const fake = ts.isObjectLiteralExpression(body) ? propertyOf(ts, body, 'fake') : null;
79
+ const reason = ts.isObjectLiteralExpression(body) ? propertyOf(ts, body, 'reason') : null;
80
+ const position = sourceFile.getLineAndCharacterOfPosition(property.getStart(sourceFile));
81
+ entries.push({
82
+ service,
83
+ fake: fake && ts.isStringLiteralLike(fake) ? fake.text : '',
84
+ reason: reason && ts.isStringLiteralLike(reason) ? reason.text.trim() : '',
85
+ line: position.line + 1,
86
+ column: position.character + 1,
87
+ });
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ export function checkTestWorldFiles(input) {
93
+ const { config, graph, context } = input;
94
+ const ts = context.ts;
95
+ const resolver = graph.resolver;
96
+ const { suffixes } = resolver.ruleParams();
97
+ const tree = treeOf(config.root);
98
+ const violations = [];
99
+ const fakeProviders = new Map();
100
+ let worldRoot = null;
101
+ let files = 0;
102
+ for (const file of [...tree.files].sort()) {
103
+ if (!file.endsWith('.ts')) continue;
104
+ const verdict = allowsFile(resolver, file);
105
+ if (!verdict || verdict.slot !== WORLD_SLOT) continue;
106
+ files += 1;
107
+ worldRoot ??= file.slice(0, file.length - verdict.relative.length);
108
+ if (verdict.relative.startsWith(FAKES_DIRECTORY)) {
109
+ const [provider, ...rest] = verdict.relative.slice(FAKES_DIRECTORY.length).split('/');
110
+ if (rest.length > 0 && !fakeProviders.has(provider)) fakeProviders.set(provider, file);
111
+ }
112
+ if (verdict.allowed) continue;
113
+ const parts = verdict.relative.slice(0, -'.ts'.length).split('.');
114
+ const roleFileAtRoot = !verdict.relative.includes('/') && parts.length >= 2 && parts.every(part => KEBAB.test(part)) && suffixes.includes(parts.at(-1));
115
+ if (roleFileAtRoot) continue;
116
+ violations.push({
117
+ ruleId: RULE, path: file, line: 1, column: 1,
118
+ message: `${file} is not allowed in the test world; src/tests/world/ holds only ${verdict.allows.join(', ')} and role-suffixed files (<name>.<role>.ts) at its root. Move it to fakes/ or kit/ (be.tests.world.kit), give it a role suffix, or delete it.`,
119
+ slot: verdict.slot,
120
+ });
121
+ }
122
+
123
+ const configPath = worldRoot === null ? null : `${worldRoot}${CONFIG_FILE}`;
124
+ const configFile = configPath === null ? null : graph.files.get(configPath) ?? null;
125
+ const literal = configFile ? configLiteral(ts, configFile.sourceFile) : null;
126
+ const environments = (literal ? stacksOf(ts, literal) : null) ?? [defaultEnvironment];
127
+ const services = [];
128
+ for (const environment of environments) {
129
+ let stack = null;
130
+ try { stack = readStack({ root: config.root, environment }); } catch { stack = null; }
131
+ for (const service of stack?.services ?? []) if (service.role !== 'service' && !services.some(known => known.name === service.name)) services.push({ ...service, environment });
132
+ }
133
+ const stackHint = `${STACKS_DIRECTORY}/${environments.join(', ')}`;
134
+ const statefulWhy = service => (STATEFUL_KINDS.has(service.kind) ? `a ${service.kind} holds data the app reads back` : service.persistent ? 'the stack gives it a persistent volume' : null);
135
+ const declared = configFile ? fakedByOf(ts, configFile.sourceFile, literal) : [];
136
+
137
+ for (const entry of declared) {
138
+ const service = services.find(candidate => candidate.name === entry.service);
139
+ const problems = [];
140
+ if (!service) problems.push(`${stackHint} declares no service ${entry.service}`);
141
+ else if (statefulWhy(service)) problems.push(`${service.name} is stateful (${statefulWhy(service)}) and always runs real`);
142
+ if (!fakeProviders.has(entry.fake)) problems.push(`there is no fakes/${entry.fake || '<fake>'}/ folder`);
143
+ if (entry.reason === '') problems.push('the reason is empty');
144
+ if (problems.length > 0) {
145
+ violations.push({
146
+ ruleId: RULE, path: configPath, line: entry.line, column: entry.column, slot: WORLD_SLOT,
147
+ message: `${configPath} declares ${entry.service} as faked by ${entry.fake || '(no fake)'}, but ${problems.join('; ')}. The exception covers only stateless compute that needs special hardware or an external model, with a reason; everything else in the stack runs real.`,
148
+ });
149
+ }
150
+ }
151
+
152
+ for (const [provider, file] of [...fakeProviders].sort(([a], [b]) => a.localeCompare(b))) {
153
+ const service = services.find(candidate => namesOfService(candidate).includes(provider));
154
+ if (!service) continue;
155
+ if (declared.some(entry => entry.service === service.name && entry.fake === provider)) continue; // judged on its config entry above
156
+ const why = statefulWhy(service);
157
+ violations.push({
158
+ ruleId: RULE, path: file, line: 1, column: 1, slot: WORLD_SLOT,
159
+ message: `fakes/${provider}/ fakes ${service.name} (${service.image}), which ${stackHint} declares: a service of the repository's own stack runs real in the test world. ${why ? `${service.name} is stateful (${why}), so no exception applies. ` : `Only stateless compute that needs special hardware or an external model may be faked, and only when ${configPath ?? `${CONFIG_FILE} in the world`} declares it in fakedBy with a reason. `}Delete the fake and let the world run the real service; fakes/ is otherwise only for external SaaS the team does not operate.`,
160
+ });
161
+ }
162
+ return { violations, coverage: { status: 'checked', files } };
163
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
3
+ * 1. the tier direction matrix: every import, re-export and type-only import between two owners must go from a tier to
4
+ * a tier its `mayImport` lists (BE_TIER_DIRECTION / FE_TIER_DIRECTION); a backend feature importing another feature
5
+ * is the one direction with its own code (BE_FEATURE_IMPORTS_FEATURE, R28), never BE_TIER_DIRECTION,
6
+ * an app never imports another app (FE_APP_ISOLATION), and a component layer imports only the layers after it;
7
+ * 2. owner cycles: a strongly connected component of the owner graph, type-only imports included (ARCH_OWNER_CYCLE),
8
+ * reported with the cycle path and the import that closes each hop.
9
+ * Importing inside one owner is always allowed; the public-entry rule stays with owners.mjs (ARCH_OWNER_EXPORT_BYPASS).
10
+ */
11
+ export const TIER_RULE_IDS = ['BE_TIER_DIRECTION', 'BE_FEATURE_IMPORTS_FEATURE', 'FE_TIER_DIRECTION', 'FE_APP_ISOLATION', 'ARCH_OWNER_CYCLE'];
12
+
13
+ const REASON_TEXT = {
14
+ tierDirection: ({ fromTier, toTier, mayImport }) => `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`,
15
+ layerOrder: ({ fromLayer, toLayer }) => `a ${fromLayer} component may import only the layers after it; it imports a ${toLayer}`,
16
+ crossApp: ({ from, to }) => `app ${from} imports app ${to}; apps never import each other, shared code is a packages/<pkg> slot`,
17
+ };
18
+
19
+ /** Strongly connected components (Tarjan, iterative) of `adjacency` (Map<node, Set<node>>); components of one node are dropped. */
20
+ export function stronglyConnected(adjacency) {
21
+ let counter = 0;
22
+ const index = new Map();
23
+ const low = new Map();
24
+ const onStack = new Set();
25
+ const stack = [];
26
+ const result = [];
27
+ for (const start of adjacency.keys()) {
28
+ if (index.has(start)) continue;
29
+ const work = [{ node: start, iterator: adjacency.get(start)[Symbol.iterator]() }];
30
+ index.set(start, counter); low.set(start, counter); counter += 1; stack.push(start); onStack.add(start);
31
+ while (work.length) {
32
+ const frame = work.at(-1);
33
+ const next = frame.iterator.next();
34
+ if (!next.done) {
35
+ const target = next.value;
36
+ if (!adjacency.has(target)) continue;
37
+ if (!index.has(target)) {
38
+ index.set(target, counter); low.set(target, counter); counter += 1; stack.push(target); onStack.add(target);
39
+ work.push({ node: target, iterator: adjacency.get(target)[Symbol.iterator]() });
40
+ } else if (onStack.has(target)) low.set(frame.node, Math.min(low.get(frame.node), index.get(target)));
41
+ continue;
42
+ }
43
+ work.pop();
44
+ if (work.length) { const parent = work.at(-1).node; low.set(parent, Math.min(low.get(parent), low.get(frame.node))); }
45
+ if (low.get(frame.node) === index.get(frame.node)) {
46
+ const component = [];
47
+ let member;
48
+ do { member = stack.pop(); onStack.delete(member); component.push(member); } while (member !== frame.node);
49
+ if (component.length > 1) result.push(component);
50
+ }
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+
56
+ /** A shortest cycle through `origin` inside `members`, as a node path that starts and ends at origin. */
57
+ function cycleThrough(adjacency, members, origin) {
58
+ const queue = [[origin]];
59
+ const seen = new Set();
60
+ while (queue.length) {
61
+ const trail = queue.shift();
62
+ for (const next of adjacency.get(trail.at(-1)) ?? []) {
63
+ if (!members.has(next)) continue;
64
+ if (next === origin) return [...trail, origin];
65
+ if (seen.has(next)) continue;
66
+ seen.add(next);
67
+ queue.push([...trail, next]);
68
+ }
69
+ }
70
+ return [origin, origin];
71
+ }
72
+
73
+ export function checkTiers(graph) {
74
+ const { resolver, profile } = graph;
75
+ const violations = [];
76
+ const tierRule = profile === 'be' ? 'BE_TIER_DIRECTION' : 'FE_TIER_DIRECTION';
77
+ let edgesChecked = 0;
78
+ let unclassified = 0;
79
+ const counts = { tierDirection: 0, layerOrder: 0, crossApp: 0 };
80
+ for (const edge of graph.edges) {
81
+ const verdict = resolver.importAllowed(edge.from, edge.to);
82
+ if (verdict.reason === 'unowned' || verdict.reason?.startsWith('slot')) { unclassified += 1; continue; }
83
+ edgesChecked += 1;
84
+ if (verdict.allowed || !REASON_TEXT[verdict.reason]) continue;
85
+ counts[verdict.reason] += 1;
86
+ const featureToFeature = profile === 'be' && verdict.reason === 'tierDirection' && verdict.fromTier === 'feature' && verdict.toTier === 'feature';
87
+ violations.push({
88
+ ruleId: verdict.reason === 'crossApp' ? 'FE_APP_ISOLATION' : featureToFeature ? 'BE_FEATURE_IMPORTS_FEATURE' : tierRule,
89
+ path: edge.from, line: edge.line, column: edge.column,
90
+ specifier: edge.specifier, resolvedPath: edge.to, typeOnly: !edge.runtime,
91
+ fromTier: verdict.fromTier ?? null, toTier: verdict.toTier ?? null,
92
+ message: `${edge.from} -> ${edge.to}: ${REASON_TEXT[verdict.reason](verdict)}${edge.runtime ? '' : ' (type-only imports count)'}.`,
93
+ });
94
+ }
95
+
96
+ // Owner cycles: nodes are owner units, edges the imports between two distinct units.
97
+ const adjacency = new Map();
98
+ const witness = new Map();
99
+ for (const edge of graph.edges) {
100
+ const a = graph.unit(edge.from);
101
+ const b = graph.unit(edge.to);
102
+ if (!a || !b || a === b) continue;
103
+ if (resolver.tierOf(edge.from) === 'none' || resolver.tierOf(edge.to) === 'none') continue;
104
+ if (!adjacency.has(a)) adjacency.set(a, new Set());
105
+ if (!adjacency.has(b)) adjacency.set(b, new Set());
106
+ adjacency.get(a).add(b);
107
+ if (!witness.has(`${a}\0${b}`)) witness.set(`${a}\0${b}`, edge);
108
+ }
109
+ const components = stronglyConnected(adjacency);
110
+ for (const component of components) {
111
+ const members = new Set(component);
112
+ const origin = [...component].sort()[0];
113
+ const cycle = cycleThrough(adjacency, members, origin);
114
+ const hops = cycle.slice(0, -1).map((unit, i) => witness.get(`${unit}\0${cycle[i + 1]}`));
115
+ const label = unit => unit.slice(unit.indexOf(':') + 1) || unit;
116
+ const first = hops[0];
117
+ violations.push({
118
+ ruleId: 'ARCH_OWNER_CYCLE',
119
+ path: first.from, line: first.line, column: first.column,
120
+ cycle: cycle.map(label),
121
+ cycleImports: hops.map(hop => ({ path: hop.from, line: hop.line, specifier: hop.specifier, typeOnly: !hop.runtime })),
122
+ componentSize: component.length,
123
+ message: `Owner cycle: ${cycle.map(label).join(' -> ')}${component.length > cycle.length - 1 ? ` (strongly connected with ${component.length} owners)` : ''}; type-only imports count.`,
124
+ });
125
+ }
126
+ return {
127
+ violations,
128
+ coverage: { status: 'checked', edgesChecked, unclassifiedEdges: unclassified, ownerUnits: adjacency.size, cycles: components.length, ...counts },
129
+ };
130
+ }
@@ -0,0 +1,112 @@
1
+ import path from 'node:path';
2
+ import { machineKit } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint rules `fetch-only-in-api-client` and
6
+ * `client-fetch-has-signal`. ESLint judges one file at a time by the spelling of a call; the machine judges the repository by
7
+ * what the checker resolves. A repository has exactly ONE transport client and ONE Outcome union, both named by slot (never
8
+ * by a path spelled here): the api package's (`fe.package.api.client`, `fe.package.api.outcome`) when the repository shares
9
+ * it, or the only app's (`fe.transport.client`, `fe.transport.outcome`) when the repository declares exactly one app.
10
+ *
11
+ * - two or more clients, or two or more Outcome unions, in the repository (two apps each keeping one, or a package one plus
12
+ * an app one) are findings on every copy;
13
+ * - an app client or app Outcome union in a repository that declares more than one app is a finding (it belongs to the package);
14
+ * - the client calls the global `fetch` (a client that never calls it has its transport somewhere else);
15
+ * - no other file references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a reference passed as a value
16
+ * (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no client at all every such
17
+ * reference is a finding that says the repository has no client;
18
+ * - no file imports an HTTP library, by static import, `import()` or `require()`;
19
+ * - every server reader (`modules/api/<domain>/read-*.ts`) imports the client (or the api package that holds it), so a reader
20
+ * is a caller of the one transport and not a second one.
21
+ */
22
+ export const TRANSPORT_OWNER_RULE_IDS = ['FE_TRANSPORT_OWNER'];
23
+
24
+ const RULE = 'FE_TRANSPORT_OWNER';
25
+ /** Packages that send HTTP requests: a second transport when a file imports one. */
26
+ const HTTP_LIBRARIES = new Set(['axios', 'ky', 'ky-universal', 'got', 'node-fetch', 'undici', 'cross-fetch', 'isomorphic-fetch', 'superagent', 'ofetch', 'whatwg-fetch']);
27
+ const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'self', 'global']);
28
+ const API_SLOT = 'fe.modules.api';
29
+ const APP_CLIENT_SLOT = 'fe.transport.client';
30
+ const APP_OUTCOME_SLOT = 'fe.transport.outcome';
31
+ const PACKAGE_API_SLOT = 'fe.package.api';
32
+ const CLIENT_SLOTS = new Set([APP_CLIENT_SLOT, 'fe.package.api.client']);
33
+ const OUTCOME_SLOTS = new Set([APP_OUTCOME_SLOT, 'fe.package.api.outcome']);
34
+ const NO_CLIENT = "the repository has no transport client. Create the one client (packages/<family>-api/src/client.ts, or the only app's modules/api/client.ts) and call it.";
35
+
36
+ export function checkTransportOwner(input) {
37
+ const { graph } = input;
38
+ const kit = machineKit(input);
39
+ const { ts, resolver } = kit;
40
+ const violations = [];
41
+ const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
42
+ const nextApps = resolver.repo.apps.filter(item => item.kind === 'next');
43
+ let readers = 0;
44
+
45
+ /** True when `node` names the global fetch: no import, and no declaration outside a lib declaration file. */
46
+ const isGlobalFetch = (checker, node) => {
47
+ if (ts.isIdentifier(node)) {
48
+ if (node.text !== 'fetch' || kit.importBinding(checker, node)) return false;
49
+ const parent = node.parent;
50
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false;
51
+ // A type position (`typeof fetch`, `typeof globalThis.fetch`) names the type of fetch and sends nothing.
52
+ if (ts.isTypeQueryNode(parent) || ts.isQualifiedName(parent)) return false;
53
+ if ((ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodDeclaration(parent) || ts.isBindingElement(parent) || ts.isParameter(parent) || ts.isVariableDeclaration(parent)) && parent.name === node) return false;
54
+ if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isPropertySignature(parent)) return false;
55
+ const declarations = kit.declarationsOf(checker, node);
56
+ return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
57
+ }
58
+ return ts.isPropertyAccessExpression(node) && node.name.text === 'fetch' && ts.isIdentifier(node.expression) && GLOBAL_OBJECTS.has(node.expression.text);
59
+ };
60
+
61
+ const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e');
62
+ const clients = files.filter(file => CLIENT_SLOTS.has(file.slot));
63
+ const outcomes = files.filter(file => OUTCOME_SLOTS.has(file.slot));
64
+ const clientRels = new Set(clients.map(file => file.rel));
65
+ const list = items => items.map(item => item.rel).join(', ');
66
+ const owned = clients.length ? `the repository has one transport (${list(clients)}). Call the client and take its Outcome.` : NO_CLIENT;
67
+
68
+ // Exactly one client and one Outcome union per repository.
69
+ for (const [items, noun] of [[clients, 'transport client'], [outcomes, 'Outcome union']]) {
70
+ for (const file of items) {
71
+ if (items.length > 1) report(file, file.sourceFile, `The repository has ${items.length} ${noun}s (${list(items)}); it has exactly one: the api package's (packages/<family>-api/src/) when the apps share it, or the only app's. Keep one and delete the others.`);
72
+ else if ((file.slot === APP_CLIENT_SLOT || file.slot === APP_OUTCOME_SLOT) && nextApps.length > 1) {
73
+ report(file, file.sourceFile, `${file.rel} is an app's ${noun} in a repository of ${nextApps.length} apps; a repository with several apps keeps its one ${noun} in the api package (packages/<family>-api/src/).`);
74
+ }
75
+ }
76
+ }
77
+
78
+ const appsWithFiles = new Set();
79
+ for (const file of files) {
80
+ const bindings = resolver.classifyPath(file.rel).bindings;
81
+ if (bindings?.app) appsWithFiles.add(bindings.app);
82
+ const checker = kit.checkerOf(file.sourceFile);
83
+ const isClient = clientRels.has(file.rel);
84
+ let clientCalls = 0;
85
+ kit.walk(file.sourceFile, node => {
86
+ if (ts.isPropertyAccessExpression(node) && isGlobalFetch(checker, node)) {
87
+ if (isClient) clientCalls += 1;
88
+ else report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
89
+ return false;
90
+ }
91
+ if (ts.isIdentifier(node) && isGlobalFetch(checker, node)) {
92
+ if (isClient) clientCalls += 1;
93
+ else report(file, node, `fetch is used outside the transport client (called, aliased or passed as a value); ${owned}`);
94
+ return false;
95
+ }
96
+ const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier
97
+ : ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require')) ? node.arguments[0] : null;
98
+ if (specifier && ts.isStringLiteralLike(specifier) && HTTP_LIBRARIES.has(specifier.text.split('/')[0])) {
99
+ report(file, node, `${specifier.text} is a second HTTP transport; the repository's one transport is built on fetch (${clients.length ? list(clients) : 'no client exists yet'}). Use the client.`, { library: specifier.text });
100
+ }
101
+ return true;
102
+ });
103
+ if (isClient && clientCalls === 0) report(file, file.sourceFile, `${file.rel} never calls the global fetch; the client is the one module that owns fetch, with its timeout and abort signal.`);
104
+ if (file.slot === API_SLOT && bindings?.app && path.posix.basename(file.rel).startsWith('read-')) {
105
+ readers += 1;
106
+ const reachesClient = graph.edges.some(edge => edge.from === file.rel && edge.runtime
107
+ && (clientRels.has(edge.to) || graph.files.get(edge.to)?.slot === PACKAGE_API_SLOT));
108
+ if (!reachesClient) report(file, file.sourceFile, `${file.rel} is a server reader that does not import the transport client; a reader fetches through the repository's one client, never through its own transport.`);
109
+ }
110
+ }
111
+ return { violations, coverage: { status: 'checked', apps: appsWithFiles.size, readers, clients: clients.length, outcomes: outcomes.length } };
112
+ }