@starci/hfs 1.0.1 → 2.0.1

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 +51 -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 +133 -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 +22 -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,61 @@
1
+ import { canonical } from './config.mjs';
2
+ import { relativePath } from './typescript.mjs';
3
+
4
+ /**
5
+ * The file and owner graph the HFS architecture checks share. Every production source file the TypeScript context
6
+ * loaded becomes a node classified by the slot manifest (config.hfs is the resolver of scripts/lib/hfs-slots.mjs);
7
+ * every import, re-export and type-only import between two of them becomes an edge. Nothing here judges: the checks
8
+ * in tiers.mjs, reachability.mjs, dead-exports.mjs, required-files.mjs, size-growth.mjs and clones.mjs read it.
9
+ *
10
+ * graph.profile 'be' | 'fe'
11
+ * graph.resolver the slot resolver (config.hfs)
12
+ * graph.files Map<rel, {abs, rel, slot, status, tier, owner, sourceFile}> owner: {slot, root, bindings} | null
13
+ * graph.edges [{from, to, runtime, line, column, specifier, reexport, edge}] (rel paths, both in graph.files)
14
+ * graph.unit(rel) the owner unit key ("<slot>:<root>") of a file: its owner, else its slot instance
15
+ * graph.ownerRoots Map<unitKey, {slot, root, tier}> of every owner instance that holds a graph file
16
+ * graph.abs(file) the repository-relative path of an absolute file the graph holds, else null
17
+ */
18
+ export function buildHfsGraph(config, context) {
19
+ const resolver = config.hfs;
20
+ const files = new Map();
21
+ const absolute = new Map();
22
+ for (const sourceFile of context.files) {
23
+ const file = canonical(sourceFile.fileName);
24
+ const rel = relativePath(config.root, file);
25
+ if (rel.startsWith('..')) continue;
26
+ const classified = resolver.classifyPath(rel);
27
+ files.set(rel, {
28
+ abs: file,
29
+ rel,
30
+ slot: classified.slot ?? null,
31
+ status: classified.status,
32
+ tier: classified.slot ? resolver.tierOf(rel) : null,
33
+ owner: classified.slot ? resolver.ownerOf(rel) : null,
34
+ sourceFile,
35
+ });
36
+ absolute.set(file, rel);
37
+ }
38
+ const edges = [];
39
+ for (const [file, list] of context.edges) {
40
+ const from = absolute.get(file);
41
+ if (!from) continue;
42
+ for (const edge of list) {
43
+ const to = absolute.get(edge.to);
44
+ if (!to || to === from) continue;
45
+ edges.push({ from, to, runtime: edge.runtime, line: edge.line, column: edge.column, specifier: edge.specifier, reexport: Boolean(edge.reexport), edge });
46
+ }
47
+ }
48
+ const unit = rel => {
49
+ const node = files.get(rel);
50
+ if (!node) return null;
51
+ if (node.owner) return `${node.owner.slot}:${node.owner.root}`;
52
+ const classified = resolver.classifyPath(rel);
53
+ return classified.slot ? `${classified.slot}:${classified.root}` : null;
54
+ };
55
+ const ownerRoots = new Map();
56
+ for (const node of files.values()) if (node.owner) {
57
+ const key = `${node.owner.slot}:${node.owner.root}`;
58
+ if (!ownerRoots.has(key)) ownerRoots.set(key, { slot: node.owner.slot, root: node.owner.root, tier: resolver.slot(node.owner.slot).tier });
59
+ }
60
+ return { profile: resolver.repo.profile, resolver, files, edges, unit, ownerRoots, abs: file => absolute.get(file) ?? null };
61
+ }
@@ -0,0 +1,521 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isIP } from 'node:net';
4
+ import { gitOutput } from '../../lib/git.mjs';
5
+ import { repositoryName } from '../../lib/repo-identity.mjs';
6
+ import { braceVariants } from '../../lib/glob.mjs';
7
+ import { createSlotResolver, loadSlotManifest, openHfs } from '../../lib/hfs-slots.mjs';
8
+ import { isFeTestPath } from '../../lib/hfs-rules/fe-no-tests.mjs';
9
+
10
+ /**
11
+ * HFS repository-tree check (knowledge/hfs/README.md): every StarCi repository is an
12
+ * apps/<app>/ monorepo on npm with a fixed root-entry allowlist; backend composition lives in
13
+ * apps/<app>/src and shared source under src/{features,modules,tests} with the three module tiers
14
+ * domain/platform/integrations; frontend source lives only under apps/<app>/src. The judged tree is
15
+ * the tracked one (`git ls-files` from the target root); a target outside a work tree falls back to
16
+ * the filesystem view so fixtures and un-tracked checkouts are judged the same way.
17
+ */
18
+
19
+ export const HFS_RULE_IDS = [
20
+ 'HFS_APPS_REQUIRED',
21
+ 'HFS_APP_LAYOUT_INVALID',
22
+ 'HFS_E2E_IN_AUTOMATIC_GATE',
23
+ 'HFS_HOOKS_PATH_REDIRECTED',
24
+ 'HFS_MODULE_TIER_INVALID',
25
+ 'HFS_PACKAGE_MANAGER_MIXED',
26
+ 'HFS_README_BADGE_NOT_LIVE',
27
+ 'HFS_README_DESCRIPTION_INVALID',
28
+ 'HFS_README_DEVELOPMENT_INCOMPLETE',
29
+ 'HFS_README_PRIVATE_URL',
30
+ 'HFS_README_SECTION_MISSING',
31
+ 'HFS_README_SECTION_ORDER',
32
+ 'HFS_README_TITLE_INVALID',
33
+ 'HFS_README_WORK_POINTER_MISSING',
34
+ 'HFS_ROOT_ENTRY_FORBIDDEN',
35
+ 'HFS_ROOT_ENTRY_MISSING',
36
+ 'HFS_ROOT_MARKDOWN_FORBIDDEN',
37
+ 'HFS_ROOT_SRC_FORBIDDEN_FE',
38
+ 'HFS_SRC_LAYOUT_INVALID',
39
+ 'HFS_STACKS_IN_FE',
40
+ 'HFS_TEST_KIND_RETIRED',
41
+ 'HFS_WORK_IN_FE',
42
+ ];
43
+
44
+ const REQUIRED_COMMON = ['.gitattributes', '.github', '.gitignore', '.husky', 'hfs.json',
45
+ 'eslint.config.mjs', 'package-lock.json', 'package.json', 'README.md',
46
+ 'sonar-project.properties', 'tsconfig.json'];
47
+ const REQUIRED_BACKEND = ['.sops.yaml', '.starcistacks', '.starciwork', 'jest.config.js', 'nest-cli.json', 'src'];
48
+ const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
49
+ const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
50
+ const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
51
+ const README_SECTIONS = ['Overview', 'Stack', 'Repository layout', 'Development'];
52
+ const BACKEND_SRC_CHILDREN = new Set(['features', 'modules', 'tests']);
53
+ const MODULE_TIERS = new Set(['domain', 'integrations', 'platform']);
54
+ // Owner test layout 2026-09-30: unit `<name>.spec.ts`, integration, e2e and contract by folder and suffix; int-spec and harness-spec stay banned.
55
+ const RETIRED_TEST_SUFFIX = /\.(?:int|harness)-spec\.[cm]?[jt]sx?$/u;
56
+ const RETIRED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
57
+ const EXTRA_TEST_CONFIG = /(?:^|\/)(?:jest[.-][^/]*(?:config\.[cm]?[jt]s|\.json)|jest-(?:e2e|int|integration|harness)[^/]*)$/u;
58
+ const NODE_ENTRIES_SKIPPED = new Set(['node_modules', '.git']);
59
+
60
+ /** The slot resolver of a repository: the caller's, else the one its hfs.json declares, else the profile's slots with no app declared. */
61
+ function resolverOf(root, profile, given) {
62
+ if (given) return given;
63
+ try { return openHfs({ repoRoot: root }); } catch { /* an invalid hfs.json is HFS_DECLARATION_INVALID's finding, judged elsewhere */ }
64
+ return createSlotResolver(loadSlotManifest(), { profile, apps: [], optionalSlots: [], connections: [] });
65
+ }
66
+
67
+ /** The first path segment of every slot of the profile that may exist: the repository root entries (contracts/, docs/, src/ ...). */
68
+ function slotRootEntries(resolver) {
69
+ const roots = new Set(['apps']);
70
+ for (const slot of resolver.slots()) {
71
+ if (slot.presence === 'forbidden') continue;
72
+ for (const variant of braceVariants(slot.path)) {
73
+ const first = variant.split('/')[0];
74
+ if (first && !/[*?<%]/u.test(first)) roots.add(first);
75
+ }
76
+ }
77
+ return roots;
78
+ }
79
+
80
+ /** The folders directly below src/tests/ that the be.tests.* slots declare (world, fixtures, integration, e2e, contract). */
81
+ function slotTestChildren(resolver) {
82
+ const children = new Set();
83
+ for (const slot of resolver.slots()) {
84
+ for (const variant of braceVariants(slot.path)) {
85
+ const match = /^src\/tests\/([^/*?<]+)\//u.exec(variant);
86
+ if (match) children.add(match[1]);
87
+ }
88
+ }
89
+ return children;
90
+ }
91
+
92
+ /**
93
+ * Whether the given root is the top level of its own Git work tree. An example under a runtime clone is a directory of
94
+ * that clone, not a work tree of its own: the clone's hooks and root are not the example's. A directory outside any work
95
+ * tree is not a top level either.
96
+ */
97
+ function ownsGitTopLevel(root) {
98
+ try {
99
+ const top = gitOutput(['rev-parse', '--show-toplevel'], { cwd: root }).trim();
100
+ const same = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
101
+ return same(fs.realpathSync(path.resolve(top)), fs.realpathSync(path.resolve(root)));
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /** The tracked paths under the given root, relative to it: the pathspec keeps a nested directory from listing the paths of its enclosing clone. */
108
+ function gitPaths(root) {
109
+ try {
110
+ // An empty index is still a Git tree. Falling back to disk in that case would count
111
+ // untracked build output as repository content.
112
+ gitOutput(['rev-parse', '--is-inside-work-tree'], { cwd: root });
113
+ const out = gitOutput(['ls-files', '--cached', '-z', '--', '.'], { cwd: root, maxBuffer: 256 * 1024 * 1024 });
114
+ return out.split('\0').filter(Boolean);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function fsHasDir(root, relative) {
121
+ try { return fs.statSync(path.join(root, ...relative.split('/'))).isDirectory(); } catch { return false; }
122
+ }
123
+
124
+ function fsHasFile(root, relative) {
125
+ try { return fs.statSync(path.join(root, ...relative.split('/'))).isFile(); } catch { return false; }
126
+ }
127
+
128
+ function fsChildren(root, relative) {
129
+ try {
130
+ return fs.readdirSync(path.join(root, ...relative.split('/')), { withFileTypes: true })
131
+ .map(entry => entry.name);
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ function fsFiles(root, relative = '') {
138
+ const out = [];
139
+ for (const name of fsChildren(root, relative || '.')) {
140
+ if (NODE_ENTRIES_SKIPPED.has(name)) continue;
141
+ const child = relative ? `${relative}/${name}` : name;
142
+ if (fsHasDir(root, child)) out.push(...fsFiles(root, child));
143
+ else out.push(child);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Uniform tree view: top entries plus children(dir)/hasDir/hasFile answers over posix relatives. */
149
+ function treeView(root) {
150
+ const tracked = gitPaths(root);
151
+ if (tracked !== null) {
152
+ const files = new Set(tracked);
153
+ return {
154
+ source: 'git',
155
+ top: [...new Set(tracked.map(file => file.split('/')[0]))],
156
+ children: dir => {
157
+ const prefix = `${dir}/`;
158
+ const names = new Set();
159
+ for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
160
+ return [...names];
161
+ },
162
+ hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
163
+ hasFile: file => files.has(file),
164
+ files: () => tracked,
165
+ };
166
+ }
167
+ return {
168
+ source: 'fs',
169
+ top: fsChildren(root, '.'),
170
+ children: dir => fsChildren(root, dir),
171
+ hasDir: dir => fsHasDir(root, dir),
172
+ hasFile: file => fsHasFile(root, file),
173
+ files: () => fsFiles(root),
174
+ };
175
+ }
176
+
177
+ function privateHost(hostname) {
178
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
179
+ if (host === 'localhost' || host === '::1' || host === '0.0.0.0' ||
180
+ /(?:\.localhost|\.local|\.internal|\.lan)$/u.test(host)) return true;
181
+ if (isIP(host) === 6) return /^(?:::|f[cd][0-9a-f]*:|fe[89ab][0-9a-f]*:)/iu.test(host);
182
+ if (!host.includes('.')) return true;
183
+ const octets = host.split('.');
184
+ if (octets.length !== 4 || !octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)) return false;
185
+ const [a, b] = octets.map(Number);
186
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 ||
187
+ a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
188
+ }
189
+
190
+ // The scripts the README Development section shows; each is required only when the managed package-scripts template of the
191
+ // profile (packages/hfs/templates/<profile>/package-scripts/package.json, the one source of the managed script names) has it.
192
+ const DEVELOPMENT_SCRIPTS = ['typecheck', 'lint:check', 'build', 'test'];
193
+ // The templates sit beside the runtime in a checkout (packages/hfs/templates) and one level above the bundled runtime of @starci/hfs.
194
+ const TEMPLATE_ROOTS = [path.resolve(import.meta.dirname, '..', '..', '..', 'packages', 'hfs', 'templates'), path.resolve(import.meta.dirname, '..', '..', '..', '..', 'templates')];
195
+ const managedScriptCache = new Map();
196
+
197
+ /** The script names of the managed package-scripts template of a profile. The template holds a {{appScripts}} placeholder, so it is read by key, not parsed as JSON. */
198
+ function managedScriptNames(profile) {
199
+ if (!managedScriptCache.has(profile)) {
200
+ const file = TEMPLATE_ROOTS.map(dir => path.join(dir, profile, 'package-scripts', 'package.json')).find(candidate => fs.existsSync(candidate));
201
+ if (!file) throw Error(`The managed package-scripts template of profile ${profile} cannot be found next to the runtime.`);
202
+ managedScriptCache.set(profile, new Set([...fs.readFileSync(file, 'utf8').matchAll(/^\s*"([A-Za-z0-9:_.-]+)":\s*"/gmu)].map(match => match[1])));
203
+ }
204
+ return managedScriptCache.get(profile);
205
+ }
206
+
207
+ /** The README command that runs a managed script: `npm test` for test, `npm run <name>` for the others (never a longer script name that starts with it). */
208
+ function scriptCommand(name) {
209
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
210
+ return new RegExp(name === 'test' ? 'npm (?:run test|test)(?![\\w:-])' : `npm run ${escaped}(?![\\w:-])`, 'u');
211
+ }
212
+
213
+ /** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
214
+ export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'be' }) {
215
+ const violations = [];
216
+ const finding = (ruleId, entry, message, line = 1) => violations.push({ ruleId, path: entry, line, column: 1, message });
217
+ for (const entry of tree.top) {
218
+ if (/\.md$/iu.test(entry) && !(runtime ? RUNTIME_ROOT_MARKDOWN : PRODUCT_ROOT_MARKDOWN).has(entry))
219
+ finding('HFS_ROOT_MARKDOWN_FORBIDDEN', entry, `Root Markdown ${entry} belongs under docs/ or the owning Work record.`);
220
+ if (NON_NPM_ENTRIES.has(entry))
221
+ finding('HFS_PACKAGE_MANAGER_MIXED', entry, `${entry} contradicts the npm package manager contract.`);
222
+ }
223
+ for (const entry of ['.gitattributes', 'README.md']) {
224
+ if (!tree.hasFile(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `Repository presentation requires root ${entry}.`);
225
+ }
226
+ if (tree.hasFile('package.json')) {
227
+ try {
228
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
229
+ if (pkg.packageManager && !/^npm@\d/u.test(pkg.packageManager))
230
+ finding('HFS_PACKAGE_MANAGER_MIXED', 'package.json', `packageManager ${pkg.packageManager} contradicts the npm package-lock.json contract.`);
231
+ } catch { /* The repository's package/config checks own unreadable or invalid JSON. */ }
232
+ }
233
+ if (!tree.hasFile('README.md')) return { violations, coverage: { status: 'checked', source: tree.source } };
234
+ let readme;
235
+ try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); }
236
+ catch {
237
+ finding('HFS_ROOT_ENTRY_MISSING', 'README.md', 'Tracked README.md is not readable.');
238
+ return { violations, coverage: { status: 'checked', source: tree.source } };
239
+ }
240
+ const lines = readme.split(/\r?\n/u);
241
+ const name = runtime ? 'StarCi' : repositoryName(root);
242
+ if (lines[0].trim().toLowerCase() !== `# ${name}`.toLowerCase())
243
+ finding('HFS_README_TITLE_INVALID', 'README.md', `README.md must start with # ${name}.`);
244
+ const description = lines.slice(1).find(line => line.trim());
245
+ if (!description || /^\s*(?:#|!\[|\[!\[)/u.test(description) || description.trim().length > 240)
246
+ finding('HFS_README_DESCRIPTION_INVALID', 'README.md', 'Place one concise description line directly below the repository name.');
247
+ const headings = lines.map((line, index) => ({ name: /^## (.+?)\s*$/u.exec(line)?.[1], index })).filter(item => item.name);
248
+ const required = [...README_SECTIONS, ...(tree.hasDir('.starciwork') ? ['Work'] : [])];
249
+ let previous = -1;
250
+ for (const section of required) {
251
+ const found = headings.find(item => item.name === section);
252
+ if (!found) finding('HFS_README_SECTION_MISSING', 'README.md', `README.md requires a ## ${section} section.`);
253
+ else if (found.index <= previous) finding('HFS_README_SECTION_ORDER', 'README.md', `## ${section} must follow the preceding standard section.`, found.index + 1);
254
+ else previous = found.index;
255
+ }
256
+ const sectionBody = section => {
257
+ const start = headings.find(item => item.name === section)?.index;
258
+ if (start === undefined) return '';
259
+ const end = headings.find(item => item.index > start)?.index ?? lines.length;
260
+ return lines.slice(start + 1, end).join('\n');
261
+ };
262
+ if (!runtime && headings.some(item => item.name === 'Development')) {
263
+ const development = sectionBody('Development');
264
+ const scripts = DEVELOPMENT_SCRIPTS.filter(name => managedScriptNames(profile).has(name));
265
+ const commands = [/npm (?:ci|install)/u, ...scripts.map(scriptCommand)];
266
+ if (commands.some(command => !command.test(development)))
267
+ finding('HFS_README_DEVELOPMENT_INCOMPLETE', 'README.md',
268
+ `Development must show npm install and the managed script commands: ${scripts.map(name => (name === 'test' ? 'npm test' : `npm run ${name}`)).join(', ')}.`);
269
+ }
270
+ if (tree.hasDir('.starciwork') && headings.some(item => item.name === 'Work') &&
271
+ !/\.starciwork\b/u.test(sectionBody('Work')))
272
+ finding('HFS_README_WORK_POINTER_MISSING', 'README.md', 'Work must point at the backend .starciwork tree.');
273
+ let fenced = false;
274
+ lines.forEach((line, index) => {
275
+ if (/^\s*```/u.test(line)) { fenced = !fenced; return; }
276
+ if (fenced) return;
277
+ const prose = line.replace(/`[^`]*`/gu, '');
278
+ for (const match of prose.matchAll(/https?:\/\/[^\s<>)"']+/giu)) {
279
+ const value = match[0].replace(/[.,;!?]+$/u, '');
280
+ try {
281
+ if (privateHost(new URL(value).hostname))
282
+ finding('HFS_README_PRIVATE_URL', 'README.md', `README URL ${value} points at a local or private host.`, index + 1);
283
+ } catch { /* Malformed URLs are outside this presentation rule. */ }
284
+ }
285
+ const badgeTargets = [
286
+ ...[...prose.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gu)].map(match => ({ alt: match[1], target: match[2] })),
287
+ ...[...prose.matchAll(/<img\b[^>]*>/giu)].map(match => ({
288
+ alt: /\balt=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
289
+ target: /\bsrc=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
290
+ })),
291
+ ];
292
+ for (const { alt, target: rawTarget } of badgeTargets) {
293
+ const target = rawTarget.trim().replace(/^<|>$/gu, '');
294
+ if (!/badge/iu.test(alt) && !/badge|shields\.io|badgen\.net/iu.test(target)) continue;
295
+ try {
296
+ const url = new URL(target);
297
+ if (url.protocol !== 'https:' || privateHost(url.hostname) ||
298
+ /^(?:img\.shields\.io|badgen\.net)$/iu.test(url.hostname) && /^\/badge\//u.test(url.pathname))
299
+ finding('HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must represent a live external HTTPS service.`, index + 1);
300
+ } catch { finding('HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must use a live external HTTPS service.`, index + 1); }
301
+ }
302
+ });
303
+ return { violations, coverage: { status: 'checked', source: tree.source } };
304
+ }
305
+
306
+ // Owner ruling 2026-09-29 (layout 2026-09-30): integration, e2e and contract run MANUALLY only. No hook, default typecheck,
307
+ // coverage run or automatic CI trigger may include those trees or run those projects. Linting the e2e files is not running them: ESLint reads them
308
+ // as syntax in the one repository-wide lint run (the factory's e2e block), so no `lint:e2e` command exists to judge.
309
+ const E2E_COMMAND = /\btest:(?:e2e|integration|contract)\b|\btypecheck:tests\b|--selectProjects\s+(?:e2e|integration|contract)\b|src\/tests\/(?:world|integration|e2e|contract)\b|jest[^\n|&;]*(?:e2e|integration|contract)/u;
310
+ const UNIT_RUN_SCRIPTS = ['test', 'test:unit', 'test:ci', 'test:affected', 'test:coverage', 'test:cov'];
311
+ // An --ignore-pattern names the e2e tree to keep it OUT of a command; it is not a run of e2e.
312
+ const runsE2e = text => E2E_COMMAND.test(String(text).replace(/--ignore-pattern[= ]+(?:"[^"]*"|'[^']*'|\S+)/gu, ''));
313
+ const withoutComments = text => text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n');
314
+
315
+ function readText(root, relative) {
316
+ try { return fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8'); } catch { return null; }
317
+ }
318
+
319
+ // A repository-local core.hooksPath that points anywhere but husky's own directory switches the commit and push
320
+ // hooks off for that clone (a lane once redirected it to skip husky), so the gate every other clone runs never ran.
321
+ // Husky itself sets core.hooksPath to .husky/_ ; that value and .husky are the only ones allowed.
322
+ const HUSKY_HOOKS_PATH = /^\.husky(?:\/_)?\/?$/u;
323
+
324
+ /** A directory that is not the top level of its own work tree has no hooks of its own: the check is not applicable there. */
325
+ function hooksPathNotRedirected({ root, finding }) {
326
+ if (!ownsGitTopLevel(root)) return { status: 'not-applicable', reason: 'the checked root is not the top level of its own Git work tree, so it has no hooks of its own' };
327
+ let value = '';
328
+ try {
329
+ value = gitOutput(['config', '--local', '--get', 'core.hooksPath'], { cwd: root }).trim();
330
+ } catch { return { status: 'checked' }; } // key not set: nothing is redirected
331
+ if (value && !HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/')))
332
+ finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
333
+ return { status: 'checked' };
334
+ }
335
+
336
+ function e2eInAutomaticGate({ root, tree, backend, finding }) {
337
+ const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
338
+ let pkg = null;
339
+ try { pkg = JSON.parse(readText(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
340
+ const scripts = pkg?.scripts ?? {};
341
+ // 1. Husky hooks and the scripts they call (transitively through `npm run <script>`).
342
+ const pending = [];
343
+ for (const hook of ['.husky/pre-commit', '.husky/pre-push']) {
344
+ const text = readText(root, hook);
345
+ if (text === null) continue;
346
+ const body = withoutComments(text);
347
+ if (runsE2e(body)) finding(rule, hook, `${hook} runs integration, e2e or contract. They are manual only; hooks run unit, lint and typecheck.`);
348
+ for (const match of body.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
349
+ }
350
+ const lintStaged = pkg?.['lint-staged'];
351
+ if (lintStaged && runsE2e(Object.values(lintStaged).flat().join('\n'))) finding(rule, 'package.json', 'lint-staged runs an integration, e2e or contract command. They are manual only.');
352
+ const called = new Set();
353
+ for (const name of pending) {
354
+ if (called.has(name)) continue;
355
+ called.add(name);
356
+ const command = scripts[name];
357
+ if (typeof command !== 'string') continue;
358
+ if (runsE2e(command)) finding(rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
359
+ for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
360
+ }
361
+ // 2. Unit and coverage scripts on a jest repository select the unit project only and exclude src/tests.
362
+ if (backend && tree.hasFile('jest.config.js')) {
363
+ for (const name of UNIT_RUN_SCRIPTS) {
364
+ const command = scripts[name];
365
+ if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
366
+ finding(rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
367
+ }
368
+ const jestConfig = readText(root, 'jest.config.js') ?? '';
369
+ if (/collectCoverageFrom/u.test(jestConfig) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestConfig))
370
+ finding(rule, 'jest.config.js', 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
371
+ }
372
+ // 3. A back end's default tsconfig excludes the world, integration, e2e and contract trees.
373
+ const tsconfigText = readText(root, 'tsconfig.json');
374
+ let tsconfig = null;
375
+ try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
376
+ if (tsconfig) {
377
+ const excludedText = JSON.stringify(tsconfig.exclude ?? []);
378
+ const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
379
+ const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
380
+ const files = tree.files();
381
+ if (backend && files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
382
+ (defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
383
+ finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
384
+ }
385
+ // 4. A workflow that starts on push or pull_request never runs e2e.
386
+ for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
387
+ const text = readText(root, file);
388
+ if (text === null) continue;
389
+ const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
390
+ if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
391
+ if (runsE2e(withoutComments(text)))
392
+ finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
393
+ }
394
+ }
395
+
396
+ export function checkHfs(config) {
397
+ const kinds = config.kinds ?? [];
398
+ const backend = kinds.includes('backend');
399
+ const frontend = kinds.includes('frontend');
400
+ if (!backend && !frontend) return { violations: [], coverage: { status: 'not-applicable' } };
401
+ const tree = treeView(config.root);
402
+ const violations = [];
403
+ const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
404
+ const resolver = resolverOf(config.root, backend ? 'be' : 'fe', config.hfs);
405
+ const presentation = checkRepoPresentation({ root: config.root, tree, profile: backend ? 'be' : 'fe' });
406
+ violations.push(...presentation.violations);
407
+
408
+ const allowed = slotRootEntries(resolver);
409
+ for (const entry of [...tree.top].sort()) {
410
+ if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
411
+ if (frontend && !backend) {
412
+ if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'A frontend repository must not hold a .starciwork tree; Work records live in the backend repository.'); continue; }
413
+ if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, 'A frontend repository must not hold .starcistacks; stack declarations live in the backend repository.'); continue; }
414
+ if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'A frontend repository keeps source only under apps/<app>/src; the root src/ tree must move.'); continue; }
415
+ }
416
+ if (frontend && !backend && (isFeTestPath(entry) || isFeTestPath(`${entry}/x`))) continue; // a test entry of a front end is FE_NO_TESTS's, the one finding of that path
417
+ if (!allowed.has(entry)) {
418
+ finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the HFS ${backend ? 'backend' : 'frontend'} allowlist.`);
419
+ }
420
+ }
421
+
422
+ const required = new Set(REQUIRED_COMMON);
423
+ if (backend) for (const entry of REQUIRED_BACKEND) required.add(entry);
424
+ for (const entry of [...required].sort()) {
425
+ if (entry === 'apps' || entry === 'README.md' || entry === '.gitattributes') continue;
426
+ if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${backend ? 'backend' : 'frontend'} HFS tree requires root entry ${entry}.`);
427
+ }
428
+
429
+ const apps = tree.children('apps').sort();
430
+ if (!tree.hasDir('apps') || apps.length === 0) {
431
+ finding('HFS_APPS_REQUIRED', 'apps', 'Every HFS repository is an apps/<app>/ monorepo; apps/ must hold at least one application.');
432
+ }
433
+ for (const app of apps) {
434
+ const missing = [];
435
+ if (!tree.hasDir(`apps/${app}`)) {
436
+ finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application entry apps/${app} must be a directory.`);
437
+ continue;
438
+ }
439
+ if (!tree.hasDir(`apps/${app}/src`)) missing.push('src/');
440
+ // A back-end app must hold exactly what the slot of its kind requires (be.app.migrate has no app.module.ts).
441
+ if (backend) for (const required of resolver.requiredFiles(`apps/${app}/src/main.ts`)) {
442
+ const directory = required.endsWith('/');
443
+ if (!(directory ? tree.hasDir(required.slice(0, -1)) : tree.hasFile(required))) missing.push(required.slice(`apps/${app}/`.length));
444
+ }
445
+ // next-env.d.ts is generated by Next and commonly ignored by Git; it is not
446
+ // a reliable tracked-tree input.
447
+ if (frontend) for (const entry of ['package.json', 'next.config.ts', 'tsconfig.json', 'postcss.config.mjs']) {
448
+ if (!tree.hasFile(`apps/${app}/${entry}`)) missing.push(entry);
449
+ }
450
+ if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
451
+ }
452
+
453
+ if (backend) {
454
+ for (const child of tree.children('src').sort()) {
455
+ if (!BACKEND_SRC_CHILDREN.has(child)) {
456
+ finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
457
+ }
458
+ }
459
+ for (const tier of tree.children('src/modules').sort()) {
460
+ if (!MODULE_TIERS.has(tier)) {
461
+ finding('HFS_MODULE_TIER_INVALID', `src/modules/${tier}`, `Module tier ${tier} is not one of domain, platform, integrations.`);
462
+ }
463
+ }
464
+ const testChildren = slotTestChildren(resolver);
465
+ for (const child of tree.children('src/tests').sort()) {
466
+ // A file directly below src/tests/ that a slot owns (src/tests/tsconfig.json, be.tool-config) is that slot's, not a stray folder.
467
+ if (!testChildren.has(child) && resolver.classifyPath(`src/tests/${child}`).status !== 'owned') {
468
+ finding('HFS_SRC_LAYOUT_INVALID', `src/tests/${child}`, `Backend src/tests/ holds only ${[...testChildren].join(', ')} and the files a slot owns there; ${child} must move.`);
469
+ }
470
+ }
471
+ }
472
+
473
+ // Retired test kinds and folders: int-spec and harness-spec are gone, so are src/tests/harness and
474
+ // live/; a backend spec sits by its kind (beside its subject, or under src/tests/{integration,e2e,contract}/).
475
+ for (const file of tree.files()) {
476
+ if (RETIRED_TEST_SUFFIX.test(file))
477
+ finding('HFS_TEST_KIND_RETIRED', file, `${file} uses a retired test kind. Only unit *.spec.ts, *.integration-spec.ts, *.e2e-spec.ts and *.contract-spec.ts exist, each in its own folder under src/tests/.`);
478
+ else if (backend && RETIRED_TEST_FOLDER.test(file))
479
+ finding('HFS_TEST_KIND_RETIRED', file, `${file} sits in a retired test folder. Unit specs sit beside their subject, flows go under src/tests/e2e/<area>/ and test infrastructure under src/tests/world/.`);
480
+ else if (backend && /^src\/tests\//u.test(file) && EXTRA_TEST_CONFIG.test(file))
481
+ finding('HFS_TEST_KIND_RETIRED', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
482
+ }
483
+
484
+ e2eInAutomaticGate({ root: config.root, tree, backend, finding });
485
+ const hooksPath = hooksPathNotRedirected({ root: config.root, finding });
486
+
487
+ return {
488
+ violations,
489
+ coverage: {
490
+ status: 'checked',
491
+ source: tree.source,
492
+ hooksPath,
493
+ rootEntries: tree.top.length,
494
+ apps,
495
+ ruleIds: [...HFS_RULE_IDS],
496
+ },
497
+ };
498
+ }
499
+
500
+ /** Keep tree findings visible even when the TypeScript architecture config is invalid. */
501
+ export function checkHfsWithoutConfig(repositoryRoot) {
502
+ let root;
503
+ try { root = fs.realpathSync(path.resolve(repositoryRoot)); } catch {
504
+ return { violations: [], coverage: { status: 'unavailable', reason: 'repository root is unavailable' } };
505
+ }
506
+ let kinds = [];
507
+ try {
508
+ const authored = JSON.parse(fs.readFileSync(path.resolve(root, 'hfs.json'), 'utf8'));
509
+ if (authored.profile === 'be') kinds = ['backend'];
510
+ else if (authored.profile === 'fe') kinds = ['frontend'];
511
+ } catch { /* A malformed hfs.json remains an HFS_DECLARATION_INVALID error. */ }
512
+ if (!kinds.length) {
513
+ try {
514
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
515
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
516
+ if (deps['@nestjs/core']) kinds.push('backend');
517
+ if (deps.next) kinds.push('frontend');
518
+ } catch { /* The architecture error still reports the missing input. */ }
519
+ }
520
+ return checkHfs({ root, kinds });
521
+ }