@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
@@ -4,48 +4,104 @@
4
4
  // writes to the repository it inspects.
5
5
  //
6
6
  // checkRepo() answers, for the tracked paths of one repository (git ls-files):
7
- // HFS_PATH_NO_SLOT a tracked path no slot owns (the nearest slot is named)
7
+ // HFS_SLOT_UNDECLARED a tracked path no slot owns (the nearest slot is named)
8
8
  // HFS_SLOT_NOT_ENABLED a tracked path in an opt-in slot the repository did not declare
9
9
  // HFS_SLOT_AMBIGUOUS two slots of equal specificity own the path (a manifest gap, reported not guessed)
10
10
  // HFS_TRACKED_MUST_BE_IGNORED a tracked path in an `ignored` slot (build output, generated files)
11
- // HFS_FORBIDDEN_PRESENT a tracked path in a forbidden / `external` slot
12
- // HFS_REQUIRED_MISSING a file or directory a required slot (or an instance of one) must contain
11
+ // HFS_FORBIDDEN_PRESENT a tracked path in a forbidden / `external` slot; in the slot be.tool-config-local (.eslintrc*,
12
+ // .eslintignore, a second eslint.config.*, another prettier or jest config) the code is HFS_TOOL_CONFIG_LOCAL instead
13
+ // HFS_MANAGED_FILE_DRIFT, HFS_TOOL_CONFIG_LOCAL (content), HFS_TS_STRICT
14
+ // the managed files: produced by packages/hfs/sync/managed.mjs, which renders the templates,
15
+ // and passed in as `extraFindings` (this module does not read the templates)
16
+ // HFS_SLOT_REQUIRED_MISSING a file or directory a required slot (or an instance of one) must contain
13
17
  // HFS_MIN_INSTANCES fewer instances of a slot than minInstances
18
+ // HFS_PLAINTEXT_SECRET (R06, hfs-rules/secrets.mjs) a plaintext secret file or value in a tracked file; a `.enc` that is no sops envelope
19
+ // HFS_STACKS_SHAPE (R10, hfs-rules/stacks.mjs) a `.starcistacks` path outside the standard shape, or a Sonar owner that is not the host
20
+ // HFS_CI_MISSING_CANON (R13, hfs-rules/pipeline.mjs) CI without the pinned `hfs check`, pre-push without typecheck or lint
21
+ // HFS_DEP_VERSION_SKEW (R14, hfs-rules/deps.mjs) a dependency at two versions in the workspace, or a nested copy in the lockfile
22
+ // HFS_CONTRACT_SNAPSHOT_DRIFT (R23, hfs-rules/contract.mjs) an uncommitted back-end snapshot, or a front-end copy that differs from it
23
+ // BE_TEST_TOPOLOGY (R47, hfs-rules/test-topology.mjs) a `.test` file, a testing/ folder, a second jest configuration
24
+ // FE_NO_TESTS (R97, hfs-rules/fe-no-tests.mjs) a front end holds a spec, e2e or test-tool file, a test script or a test dependency; no exception
25
+ // BE_SPEC_PLACEMENT (R102, hfs-rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
26
+ // HFS_REPO_LOCAL_CHECK (R103, hfs-rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
27
+ // HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
28
+ // HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
29
+ // FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
30
+ // HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
31
+ // HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
32
+ // both are passed in as `extraFindings`: this module reads no template and starts no tool
14
33
  // HFS_CANON_PIN_DRIFT a dependency whose declared version is not the pinned one
34
+ // BE_SOURCE_FORM (be) a tracked src/apps .ts file whose name is not index.ts, main.ts, a migration or <kebab>.<suffix>.ts with a suffix of ruleParams.be.suffixes
15
35
  // HFS_SIZE_SOFT_BACKLOG (info, report-only, never fails) a source file above ruleParams fileLines.soft
16
- // Every finding carries its code and the Vietnamese why text of modules/kernel/failure-codes.yaml. Only `error`
36
+ // HFS_EMPTY_DIR a directory with no file below it (git never tracks one), outside .git, node_modules and ignored slots
37
+ // HFS_GHOST_TREE an empty directory beside a sibling within two edits of its name (business / bussiness)
38
+ // HFS_UNTRACKED_ROOT_ENTRY an entry git neither tracks nor ignores, outside an `ignored` slot (R03)
39
+ // checkRepository() is the whole `hfs check`: checkRepo() plus the architecture machine (scripts/checks/architecture.mjs, one
40
+ // implementation; the published bundle carries a byte copy), its violations and errors reported as findings under their own
41
+ // codes; `fast` limits both to the owners changed since the merge-base. Every finding carries its code and the Vietnamese why text of modules/kernel/failure-codes.yaml. Only `error`
17
42
  // findings fail the check.
18
43
  import fs from 'node:fs';
19
44
  import path from 'node:path';
20
- import { execFileSync } from 'node:child_process';
21
45
  import { skillRoot } from '../../engine/runtime-root.mjs';
46
+ import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture/index.mjs';
22
47
  import { parseYaml } from '../../engine/yaml.mjs';
23
48
  import { HFS_DECLARATION_FILE, HfsSlotsError, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
49
+ import { allowsFile } from './hfs-allows.mjs';
50
+ import { isDir } from './fs-kind.mjs';
51
+ import { gitOutput } from './git.mjs';
24
52
  import { posixPath } from './path-key.mjs';
53
+ import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
54
+ import { contractFindings } from './hfs-rules/contract.mjs';
55
+ import { depFindings } from './hfs-rules/deps.mjs';
56
+ import { frontendFindings } from './hfs-rules/frontend.mjs';
57
+ import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
58
+ import { pipelineFindings } from './hfs-rules/pipeline.mjs';
59
+ import { proofCommandFindings } from './hfs-rules/proof-commands.mjs';
60
+ import { repoLocalCheckFindings } from './hfs-rules/repo-local-checks.mjs';
61
+ import { readJson } from './hfs-rules/read.mjs';
62
+ import { secretFindings, slotOwnsSecrets } from './hfs-rules/secrets.mjs';
63
+ import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
64
+ import { stacksFindings } from './hfs-rules/stacks.mjs';
65
+ import { testTopologyFindings } from './hfs-rules/test-topology.mjs';
66
+ import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
25
67
 
26
68
  export const CANON_PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
27
69
  export const FAILURE_CODES_FILE = 'modules/kernel/failure-codes.yaml';
70
+ /**
71
+ * The codes `hfs check` and `hfs init` report when they cannot judge (an unreadable repository, a refused declaration or
72
+ * manifest, an init that cannot proceed): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
73
+ */
74
+ export const REFUSAL_CODES = Object.freeze([
75
+ 'HFS_INIT_EXISTS', 'HFS_INIT_UNDETECTED', 'HFS_REPO_UNREADABLE',
76
+ 'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID', 'HFS_FORMAT_TOOL_MISSING',
77
+ ]);
28
78
  /** The codes this module emits that are not the slot loader's own: the why bundle of packages/hfs ships exactly these plus the loader's. */
29
79
  export const CHECK_CODES = Object.freeze([
30
- 'HFS_PATH_NO_SLOT', 'HFS_SLOT_NOT_ENABLED', 'HFS_SLOT_AMBIGUOUS', 'HFS_TRACKED_MUST_BE_IGNORED', 'HFS_FORBIDDEN_PRESENT',
31
- 'HFS_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG',
32
- 'HFS_INIT_EXISTS', 'HFS_INIT_UNDETECTED', 'HFS_REPO_UNREADABLE',
33
- 'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID',
80
+ 'HFS_SLOT_UNDECLARED', 'HFS_SLOT_NOT_ENABLED', 'HFS_SLOT_AMBIGUOUS', 'HFS_TRACKED_MUST_BE_IGNORED', 'HFS_FORBIDDEN_PRESENT',
81
+ 'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
82
+ 'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
83
+ 'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
84
+ 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
85
+ 'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
86
+ 'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
87
+ ...REFUSAL_CODES,
34
88
  ]);
89
+ /** Every code `hfs check` can report: its own and every code the architecture machine can emit (derived from the machine's rule id lists). */
90
+ export const ALL_CHECK_CODES = Object.freeze([...new Set([...CHECK_CODES, ...ARCHITECTURE_RULE_IDS])].sort());
35
91
  const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/;
36
92
  const VAR = /<([a-z][a-z0-9-]*)>/g;
37
93
  const DEP_SECTIONS = ['dependencies', 'devDependencies'];
38
94
 
39
95
  const refuse = (code, message, details = {}) => { throw new HfsSlotsError(code, message, details); };
40
96
 
41
- /** {code: {title_vi, meaning_vi, nextStep_vi}} for the codes asked for, read from the failure-code catalog under `root`. */
97
+ /** {code: {title, title_vi, meaning_vi, nextStep_vi}} for the codes asked for, read from the failure-code catalog under `root`. */
42
98
  export function readWhy(root = skillRoot, codes = CHECK_CODES) {
43
99
  const catalog = parseYaml(fs.readFileSync(path.join(root, FAILURE_CODES_FILE), 'utf8'));
44
100
  const why = {};
45
101
  for (const code of codes) {
46
102
  const entry = catalog?.[code];
47
103
  if (!entry) refuse('HFS_MANIFEST_INVALID', `${FAILURE_CODES_FILE} has no entry for ${code}`, { code });
48
- why[code] = { titleVi: entry.title_vi, whyVi: entry.meaning_vi, nextStepVi: entry.nextStep_vi };
104
+ why[code] = { title: entry.title, titleVi: entry.title_vi, whyVi: entry.meaning_vi, nextStepVi: entry.nextStep_vi };
49
105
  }
50
106
  return why;
51
107
  }
@@ -54,7 +110,7 @@ export function readWhy(root = skillRoot, codes = CHECK_CODES) {
54
110
  export function trackedFiles(repoRoot) {
55
111
  let out;
56
112
  try {
57
- out = execFileSync('git', ['-C', repoRoot, 'ls-files', '-z', '--cached', '--exclude-standard'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
113
+ out = gitOutput(['ls-files', '-z', '--cached', '--exclude-standard'], { dir: repoRoot, maxBuffer: 256 * 1024 * 1024 });
58
114
  } catch (error) {
59
115
  refuse('HFS_REPO_UNREADABLE', `${repoRoot} is not a readable Git work tree (${String(error?.stderr ?? error?.message ?? error).trim().split('\n')[0]})`, { repoRoot });
60
116
  }
@@ -71,10 +127,12 @@ export function openRepo({ repoRoot, root = skillRoot, manifest = loadSlotManife
71
127
 
72
128
  const pinnedSpec = (spec, pin) => (spec === pin.version ? null : `declared ${spec}, pinned ${pin.version}`);
73
129
 
74
- function pinFindings({ repoRoot, files, profile, root }) {
75
- const pins = parseYaml(fs.readFileSync(path.join(root, CANON_PINS_FILE), 'utf8'))?.pins ?? {};
130
+ /** The pin map of knowledge/hfs/canon-pins.yaml under `root`. */
131
+ const readPins = (root) => parseYaml(fs.readFileSync(path.join(root, CANON_PINS_FILE), 'utf8'))?.pins ?? {};
132
+
133
+ function pinFindings({ repoRoot, files, profile, pins, only }) {
76
134
  const findings = [];
77
- for (const file of files.filter((f) => f === 'package.json' || f.endsWith('/package.json'))) {
135
+ for (const file of files.filter((f) => (f === 'package.json' || f.endsWith('/package.json')) && (!only || only.has(f)))) {
78
136
  let pkg;
79
137
  try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
80
138
  for (const [name, pin] of Object.entries(pins)) {
@@ -90,6 +148,53 @@ function pinFindings({ repoRoot, files, profile, root }) {
90
148
  return findings;
91
149
  }
92
150
 
151
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
152
+ const SOURCE_ROOT = /^(?:src|apps)\//;
153
+ const FREE_NAMES = new Set(['index.ts', 'main.ts']);
154
+ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
155
+
156
+ /**
157
+ * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
158
+ * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
159
+ * as api.composition.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
160
+ * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
161
+ */
162
+ function sourceFormFindings({ files, resolver }) {
163
+ const { suffixes, bannedSuffixes } = resolver.ruleParams();
164
+ // A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
165
+ // anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
166
+ const boundSuffixes = new Map();
167
+ for (const slot of resolver.slots()) {
168
+ const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
169
+ if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
170
+ }
171
+ const findings = [];
172
+ for (const file of files) {
173
+ if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
174
+ const c = resolver.classifyPath(file);
175
+ if (c.status !== 'owned' || c.tracking === 'ignored') continue;
176
+ const base = path.posix.basename(file);
177
+ if (FREE_NAMES.has(base)) continue;
178
+ // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
179
+ const slot = resolver.slot(c.slot);
180
+ if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
181
+ // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
182
+ const admitted = allowsFile(resolver, file);
183
+ if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
184
+ if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
185
+ const parts = base.slice(0, -'.ts'.length).split('.');
186
+ const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
187
+ if (banned) {
188
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})` });
189
+ } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
190
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
191
+ } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
192
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
193
+ }
194
+ }
195
+ return findings;
196
+ }
197
+
93
198
  /** Instances (slot, root, bindings) present in the tracked tree, for every slot that names required files or a minimum. */
94
199
  function instancesOf(resolver, files) {
95
200
  const found = new Map();
@@ -117,7 +222,7 @@ const requiredOf = (slot, instance) => (slot.requires ?? []).map((entry) => {
117
222
  const appOf = (p) => /^apps\/([^/]+)\//.exec(p)?.[1];
118
223
 
119
224
  function withWhy(findings, why) {
120
- return findings.map((f) => ({ ...f, titleVi: why[f.code].titleVi, whyVi: why[f.code].whyVi, nextStepVi: why[f.code].nextStepVi }));
225
+ return findings.map((f) => ({ ...f, title: why[f.code].title, titleVi: why[f.code].titleVi, whyVi: why[f.code].whyVi, nextStepVi: why[f.code].nextStepVi }));
121
226
  }
122
227
 
123
228
  function summarize(findings) {
@@ -133,12 +238,36 @@ function summarize(findings) {
133
238
  };
134
239
  }
135
240
 
241
+ /** The tree findings of R03: empty directories, ghost siblings, untracked entries outside an ignored slot. */
242
+ function treeFindings({ repoRoot, resolver }) {
243
+ const inIgnoredSlot = (rel) => resolver.classifyPath(`${rel}/.probe`).tracking === 'ignored';
244
+ const findings = [];
245
+ const facts = treeFacts(readTree(repoRoot, { isIgnored: inIgnoredSlot }));
246
+ for (const { path: dir, below } of facts.empty) {
247
+ findings.push({ code: 'HFS_EMPTY_DIR', level: 'error', path: dir, below, message: `${dir} has no file below it${below ? ` (nor in its ${below} sub-director${below === 1 ? 'y' : 'ies'})` : ''}; git tracks no empty directory, so it is a leftover` });
248
+ }
249
+ for (const { path: dir, of, distance } of facts.ghosts) {
250
+ findings.push({ code: 'HFS_GHOST_TREE', level: 'error', path: dir, of, distance, message: `${dir} is empty and ${distance} edit${distance === 1 ? '' : 's'} from its sibling ${of}: a renamed or misspelt structure that was never removed` });
251
+ }
252
+ for (const entry of untrackedEntries(repoRoot)) {
253
+ const bare = entry.replace(/\/$/, '');
254
+ const ignored = entry.endsWith('/') ? inIgnoredSlot(bare) : resolver.classifyPath(bare).tracking === 'ignored';
255
+ if (!ignored) findings.push({ code: 'HFS_UNTRACKED_ROOT_ENTRY', level: 'error', path: bare, message: `${entry} is neither tracked nor git-ignored, and no ignored slot owns it` });
256
+ }
257
+ return findings;
258
+ }
259
+
136
260
  /**
137
261
  * The check of one repository. `declaration` overrides hfs.json (a dry run over a repository that has none); `files`
138
- * overrides git ls-files (specs). Returns {ok, profile, apps, manifest, tracked, findings, counts}; a missing or invalid
139
- * hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never an exception.
262
+ * overrides git ls-files (specs). `only` (a list of paths) limits the per-path checks (slot, pin, size) to those paths; the
263
+ * checks of the tree as a whole (required files, minimum instances, empty directories, untracked entries) are not
264
+ * per-path. `tree: false` skips the file-system checks (empty directories, ghosts, untracked); they also do not run
265
+ * over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the same repository (the
266
+ * managed files), judged and counted with this module's own. Returns {ok, profile, apps, manifest, tracked, findings,
267
+ * counts}; a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never
268
+ * an exception.
140
269
  */
141
- export function checkRepo({ repoRoot, root = skillRoot, declaration, files, manifest = loadSlotManifest({ root }) }) {
270
+ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }) }) {
142
271
  const why = readWhy(root);
143
272
  let repo;
144
273
  try {
@@ -153,17 +282,24 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, mani
153
282
  const trackedSet = new Set(tracked);
154
283
  const present = (p) => (p.endsWith('/') ? tracked.some((f) => f.startsWith(p)) : trackedSet.has(p));
155
284
  const findings = [];
285
+ const scoped = only ? new Set(only) : null;
286
+ const inScope = (file) => !scoped || scoped.has(file);
156
287
 
157
288
  for (const file of tracked) {
289
+ if (!inScope(file)) continue;
290
+ if (repo.profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
158
291
  const c = resolver.classifyPath(file);
159
292
  if (c.status === 'no-slot') {
160
- findings.push({ code: 'HFS_PATH_NO_SLOT', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
293
+ findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
161
294
  } else if (c.status === 'ambiguous') {
162
295
  findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
163
296
  } else if (c.status === 'not-enabled') {
164
297
  findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
165
298
  } else if (c.status === 'forbidden') {
166
- findings.push({ code: 'HFS_FORBIDDEN_PRESENT', level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
299
+ const slot = resolver.slot(c.slot);
300
+ if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
301
+ const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
302
+ findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
167
303
  } else if (c.tracking === 'ignored') {
168
304
  findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
169
305
  }
@@ -176,7 +312,7 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, mani
176
312
  if (missing.has(key) || present(p)) return;
177
313
  missing.add(key);
178
314
  const app = appOf(p);
179
- findings.push({ code: 'HFS_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
315
+ findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
180
316
  };
181
317
  for (const entry of required.paths) missingFile(entry.slot, entry.path, entry.via);
182
318
  const instances = instancesOf(resolver, tracked);
@@ -187,26 +323,117 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, mani
187
323
  if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
188
324
  }
189
325
 
190
- findings.push(...pinFindings({ repoRoot, files: tracked, profile: repo.profile, root }));
326
+ const pins = readPins(root);
327
+ findings.push(...pinFindings({ repoRoot, files: tracked, profile: repo.profile, pins, only: scoped }));
328
+
329
+ // The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-repository, cheap, no tool run.
330
+ const secrets = secretFindings({ repoRoot, files: tracked.filter(inScope), resolver });
331
+ const declared = declaration === undefined ? readJson(repoRoot, 'hfs.json') : declaration;
332
+ if (repo.profile === 'be') findings.push(...sourceFormFindings({ files: tracked.filter(inScope), resolver }));
333
+ findings.push(
334
+ ...secrets,
335
+ ...depFindings({ repoRoot, files: tracked }),
336
+ ...pipelineFindings({ repoRoot, files: tracked, pins }),
337
+ ...contractFindings({ repoRoot, files: tracked, repo, resolver, stacks: declared?.stacks }),
338
+ ...repoLocalCheckFindings({ repoRoot, files: tracked }),
339
+ ...lintSuppressionFindings({ repoRoot, files: tracked }),
340
+ ...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...specPlacementFindings({ files: tracked, resolver }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
341
+ ...extraFindings,
342
+ );
191
343
 
192
344
  const soft = resolver.ruleParams().fileLines.soft;
193
345
  for (const file of tracked) {
194
- if (!SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
346
+ if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
195
347
  let lines;
196
348
  try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
197
349
  if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
198
350
  }
199
351
 
352
+ if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
353
+
200
354
  const finished = withWhy(findings, why);
201
355
  const counts = summarize(finished);
202
356
  return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps: repo.apps, tracked: tracked.length, findings: finished, counts };
203
357
  }
204
358
 
359
+ // ------------------------------------------------------------------------------------------ the whole check
360
+
361
+ const gitOut = (repoRoot, args) => gitOutput(args, { dir: repoRoot, maxBuffer: 256 * 1024 * 1024 });
362
+
363
+ /** The merge-base of HEAD with `base`, else with origin/main, else with main; null when none resolves. */
364
+ function mergeBaseOf(repoRoot, base) {
365
+ for (const ref of base ? [base] : ['origin/main', 'main']) {
366
+ try {
367
+ const sha = gitOut(repoRoot, ['merge-base', 'HEAD', ref]).trim();
368
+ if (sha) return sha;
369
+ } catch { /* this ref has no merge-base with HEAD; try the next */ }
370
+ }
371
+ return null;
372
+ }
373
+
374
+ /** Tracked paths that differ from the merge-base (commits, staged and unstaged edits; deletions are not paths to judge). */
375
+ export function changedSince(repoRoot, base) {
376
+ const sha = mergeBaseOf(repoRoot, base);
377
+ if (!sha) {
378
+ throw new Error(base
379
+ ? `--base ${base} has no merge-base with HEAD; pass a ref this branch descends from`
380
+ : '--fast needs a merge-base with origin/main or main and found none; run `git fetch origin main` or pass --base <ref>');
381
+ }
382
+ const files = gitOut(repoRoot, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', sha]).split('\0').filter(Boolean).map(posixPath);
383
+ return { base: sha, files };
384
+ }
385
+
386
+ /** The machine's violations and errors as findings: each keeps the machine's rule id as its code. */
387
+ function machineFindings(report) {
388
+ const of = (item) => ({ code: item.ruleId, level: 'error', ...(item.path ? { path: item.path } : {}), ...(item.line ? { line: item.line, column: item.column } : {}), source: 'machine', message: `${item.path ? `${item.path}${item.line ? `:${item.line}` : ''}: ` : ''}${item.message}` });
389
+ return [...report.errors.map(of), ...report.violations.map(of)];
390
+ }
391
+
392
+ /**
393
+ * The whole `hfs check` of one repository: checkRepo() (slots, pins, size, tree) and then the architecture machine over the
394
+ * same work tree, its violations and errors merged in as findings with the Vietnamese why of their codes. `fast` judges
395
+ * only what changed since the merge-base (`base` names another ref): the per-path slot and pin checks on the changed
396
+ * paths, the machine on the owners of the changed source files without clones and dead exports, and no file-system tree
397
+ * checks. `extraFindings` are the findings of the emitters that render templates or run the repository's formatter
398
+ * (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing merge-base under `fast` is an Error, never a silent full pass.
399
+ */
400
+ export function checkRepository({ repoRoot, root = skillRoot, fast = false, base, extraFindings = [], manifest = loadSlotManifest({ root }), machine = checkArchitecture }) {
401
+ const changed = fast ? changedSince(repoRoot, base) : null;
402
+ const baseSha = changed ? changed.base : (base ? (mergeBaseOf(repoRoot, base) ?? refuse('HFS_REPO_UNREADABLE', `--base ${base} has no merge-base with HEAD`, { repoRoot, base })) : undefined);
403
+ const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, ...(changed ? { only: changed.files, tree: false } : {}) });
404
+ if (slotResult.profile === null) return { ...slotResult, machine: { status: 'skipped', reason: 'hfs.json is not valid' } };
405
+
406
+ let paths;
407
+ if (changed) {
408
+ const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, repoRoot));
409
+ paths = [...new Set(changed.files.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
410
+ if (!paths.length) return { ...slotResult, machine: { status: 'skipped', reason: 'no changed source file', base: changed.base }, fast: { base: changed.base, changed: changed.files.length } };
411
+ }
412
+ let report;
413
+ try {
414
+ report = machine({ repositoryRoot: repoRoot, base: baseSha, ...(changed ? { paths, fast: true } : {}) });
415
+ } catch (error) {
416
+ report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
417
+ }
418
+ const found = machineFindings(report);
419
+ const why = readWhy(root, [...new Set(found.map((f) => f.code))]);
420
+ const findings = [...slotResult.findings, ...withWhy(found, why)];
421
+ const counts = summarize(findings);
422
+ return {
423
+ ...slotResult,
424
+ ok: counts.error === 0,
425
+ findings,
426
+ counts,
427
+ machine: { status: 'ran', files: report.files, kinds: report.kinds, ...(changed ? { paths, base: changed.base } : {}) },
428
+ ...(changed ? { fast: { base: changed.base, changed: changed.files.length } } : {}),
429
+ };
430
+ }
431
+
205
432
  // --------------------------------------------------------------------------------------------------- explain
206
433
 
207
434
  const TEST_KIND = {
208
- 'unit-beside': 'a unit spec beside each source file (<name>.spec.ts / .spec.tsx) in this slot',
209
- e2e: 'an e2e spec (*.e2e-spec.ts or a Playwright spec) covering the flow; no unit spec is required',
435
+ 'unit-beside': 'a <name>.service.spec.ts beside each <name>.service.ts in this slot (only services are unit-tested)',
436
+ e2e: 'an integration, e2e or contract spec covering the flow; no unit spec is required',
210
437
  none: 'no test is required for files in this slot',
211
438
  };
212
439
 
@@ -217,7 +444,7 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
217
444
  const why = readWhy(root);
218
445
  const c = resolver.classifyPath(input);
219
446
  if (c.status === 'no-slot') {
220
- return { path: c.path, status: 'no-slot', code: 'HFS_PATH_NO_SLOT', nearest: c.nearest, titleVi: why.HFS_PATH_NO_SLOT.titleVi, whyVi: why.HFS_PATH_NO_SLOT.whyVi };
447
+ return { path: c.path, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: c.nearest, titleVi: why.HFS_SLOT_UNDECLARED.titleVi, whyVi: why.HFS_SLOT_UNDECLARED.whyVi };
221
448
  }
222
449
  if (c.status === 'ambiguous') return { path: c.path, status: 'ambiguous', code: 'HFS_SLOT_AMBIGUOUS', candidates: c.candidates, titleVi: why.HFS_SLOT_AMBIGUOUS.titleVi, whyVi: why.HFS_SLOT_AMBIGUOUS.whyVi };
223
450
  const slot = resolver.slot(c.slot);
@@ -248,7 +475,6 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
248
475
  // ------------------------------------------------------------------------------------------------- init
249
476
 
250
477
  const SLUG_SUFFIX = /-(backend|be|frontend|fe|api|web|app)$/;
251
- const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
252
478
  const exists = (p) => fs.existsSync(p);
253
479
  const readPackage = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } };
254
480
  const depsOf = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
@@ -0,0 +1,126 @@
1
+ // contract.mjs - HFS_CONTRACT_SNAPSHOT_DRIFT (R23): the contract is committed and the front-end copy equals the back end's.
2
+ // back end a repository with a GraphQL transport commits `contracts/<app>/schema.graphql` for each api app, and an api app with
3
+ // an operation table commits `contracts/<app>/openapi.json`
4
+ // front end every `apps/<app>/src/modules/api/contract/<be-app>.{graphql,json}` is hash-equal to the sibling back end's
5
+ // `contracts/<be-app>/schema.graphql` (openapi.json for .json); hfs.json `stacks` names the sibling
6
+ // repository, and a sibling that is not checked out is reported (info), not compared
7
+ // The snapshots are generated by `npm run contract:emit` (`hfs emit-contracts`, packages/hfs/emit/contracts.mjs): printSchema of the
8
+ // resolvers the app root composes, and the OpenAPI 3.1 document of the typed operation table `apps/<app>/src/operations.ts`.
9
+ // That the committed snapshot equals what the app emits now is contractEmitFindings (full pass only, never --fast): it emits every
10
+ // api app into a temp directory through the injected `emit` (packages/hfs/emit) and compares hashes; an emit that cannot run is a finding.
11
+ import { createHash } from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { found } from './read.mjs';
16
+ import { safeRemoveTree } from '../safe-remove.mjs';
17
+
18
+ export const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
19
+ export const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
20
+ const COPY = /^apps\/([^/]+)\/src\/modules\/api\/contract\/([^/]+)\.(graphql|json)$/;
21
+ const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
22
+
23
+ /** sha256 of a file's text with line endings folded, or null when it cannot be read. */
24
+ export function contractHash(file) {
25
+ try { return createHash('sha256').update(fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')).digest('hex'); } catch { return null; }
26
+ }
27
+
28
+ /** The findings of R23 for the repository at `repoRoot`. `stacks` is hfs.json's sibling back-end path (front end only). */
29
+ export function contractFindings({ repoRoot, files, repo, resolver, stacks }) {
30
+ const findings = [];
31
+ if (repo.profile === 'be') {
32
+ const serves = files.some((file) => resolver.classifyPath(file).slot === GRAPHQL_TRANSPORT_SLOT);
33
+ const tracked = new Set(files);
34
+ for (const app of repo.apps.filter((a) => a.kind === 'api')) {
35
+ const operations = `contracts/${app.name}/${SNAPSHOT_OF.json}`;
36
+ if (tracked.has(`apps/${app.name}/src/operations.ts`) && !tracked.has(operations)) findings.push(found(CONTRACT_SNAPSHOT_DRIFT, operations, `${app.name} declares an operation table (apps/${app.name}/src/operations.ts) but ${operations} is not committed; declare be.contract.openapi in hfs.json optionalSlots, run \`npm run contract:emit\` and commit the snapshot`, { app: app.name }));
37
+ if (!serves) continue;
38
+ const snapshot = `contracts/${app.name}/${SNAPSHOT_OF.graphql}`;
39
+ if (!tracked.has(snapshot)) findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${app.name} serves GraphQL but ${snapshot} is not committed; declare be.contract.graphql in hfs.json optionalSlots, run \`npm run contract:emit\` and commit the snapshot`, { app: app.name }));
40
+ }
41
+ return findings;
42
+ }
43
+ for (const file of files) {
44
+ const match = COPY.exec(file);
45
+ if (!match) continue;
46
+ const [, app, beApp, ext] = match;
47
+ if (!stacks) {
48
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, file, `${file} is a contract copy but hfs.json names no sibling back end (stacks), so it cannot be compared with the back end's snapshot`, { app }));
49
+ continue;
50
+ }
51
+ const sibling = path.resolve(repoRoot, stacks);
52
+ if (!fs.existsSync(sibling)) {
53
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, file, `${file} was not compared: the sibling back end ${stacks} is not checked out here`, { app, level: 'info' }));
54
+ continue;
55
+ }
56
+ const snapshot = `contracts/${beApp}/${SNAPSHOT_OF[ext]}`;
57
+ const theirs = contractHash(path.join(sibling, snapshot));
58
+ if (theirs === null) {
59
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, file, `${stacks}/${snapshot} does not exist, so ${file} copies nothing; the back end commits its contract first`, { app, snapshot }));
60
+ continue;
61
+ }
62
+ const ours = contractHash(path.join(repoRoot, file));
63
+ if (ours !== theirs) findings.push(found(CONTRACT_SNAPSHOT_DRIFT, file, `${file} (${String(ours).slice(0, 12)}) differs from ${stacks}/${snapshot} (${theirs.slice(0, 12)}); run \`npm run contract:pull\``, { app, snapshot }));
64
+ }
65
+ return findings;
66
+ }
67
+
68
+ /** The first lines of an error, enough to act on. */
69
+ const errorText = (error) => String(error?.message ?? error).split(/\r?\n/).filter(Boolean).slice(0, 4).join(' | ');
70
+
71
+ /**
72
+ * R23, full pass: each committed `contracts/<app>/schema.graphql` and `contracts/<app>/openapi.json` equals what `emit` writes for
73
+ * the app now. `emit({ repoRoot, declaration, outDir })` is `emitContracts` of packages/hfs/emit and answers `{ written, skipped }`;
74
+ * it is injected because the runtime scripts do not import the hfs package. Every api app is emitted on its own into a temp
75
+ * directory, so one app that cannot emit does not hide the others; an uncommitted snapshot is contractFindings' finding. Answers
76
+ * `{ findings, apps }`, `apps` being what was judged: `[{ app, artifact, status }]`, `artifact` being `schema.graphql` or
77
+ * `openapi.json` and `status` one of `fresh`, `stale`, `not-committed`, `none` (nothing emitted, nothing committed; GraphQL only),
78
+ * `left-behind` (committed, nothing emitted) or `emit-failed`.
79
+ */
80
+ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
81
+ const findings = [];
82
+ const apps = [];
83
+ if (repo.profile !== 'be') return { findings, apps };
84
+ const tracked = new Set(files);
85
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'hfs-contracts-'));
86
+ try {
87
+ for (const app of repo.apps.filter((a) => a.kind === 'api')) {
88
+ const artifacts = [{ artifact: SNAPSHOT_OF.graphql, what: 'serves no GraphQL any more' }, { artifact: SNAPSHOT_OF.json, what: 'declares no operations any more' }];
89
+ let emitted;
90
+ try {
91
+ emitted = emit({ repoRoot, declaration: { apps: [app] }, outDir: scratch });
92
+ } catch (error) {
93
+ for (const { artifact } of artifacts) apps.push({ app: app.name, artifact, status: 'emit-failed' });
94
+ const snapshot = `contracts/${app.name}/${SNAPSHOT_OF.graphql}`;
95
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `contracts/${app.name} cannot be verified: \`hfs emit-contracts\` failed for ${app.name}: ${errorText(error)}`, { app: app.name, drift: 'emit-failed' }));
96
+ continue;
97
+ }
98
+ for (const { artifact, what } of artifacts) {
99
+ const snapshot = `contracts/${app.name}/${artifact}`;
100
+ const committed = tracked.has(snapshot) ? contractHash(path.join(repoRoot, snapshot)) : null;
101
+ if (!emitted.written.includes(snapshot)) {
102
+ if (committed === null) {
103
+ if (artifact === SNAPSHOT_OF.graphql) apps.push({ app: app.name, artifact, status: 'none' });
104
+ continue;
105
+ }
106
+ apps.push({ app: app.name, artifact, status: 'left-behind' });
107
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} is committed but ${app.name} ${what}; delete it`, { app: app.name, drift: 'left-behind' }));
108
+ continue;
109
+ }
110
+ const fresh = contractHash(path.join(scratch, snapshot));
111
+ if (committed === null) {
112
+ // the uncommitted snapshot itself is reported by contractFindings above; here it only counts as not judged
113
+ apps.push({ app: app.name, artifact, status: 'not-committed' });
114
+ } else if (committed === fresh) {
115
+ apps.push({ app: app.name, artifact, status: 'fresh' });
116
+ } else {
117
+ apps.push({ app: app.name, artifact, status: 'stale' });
118
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} (${committed.slice(0, 12)}) differs from what ${app.name} emits now (${String(fresh).slice(0, 12)}); run \`npm run contract:emit\` and commit the result`, { app: app.name, drift: 'stale' }));
119
+ }
120
+ }
121
+ }
122
+ } finally {
123
+ safeRemoveTree(scratch);
124
+ }
125
+ return { findings, apps };
126
+ }
@@ -0,0 +1,63 @@
1
+ // deps.mjs - HFS_DEP_VERSION_SKEW (R14): one version per dependency in the workspace.
2
+ // - the root and every workspace package.json declare a dependency at one spec (a `file:`, `link:` or `workspace:` spec is
3
+ // a workspace link, not a version);
4
+ // - a dependency the root `overrides` pins to a version (a string that is not a `$name` reference) is declared at that version everywhere
5
+ // it is declared: an override the manifests disagree with is a second version in disguise;
6
+ // - the lockfile (package-lock.json, read, never installed) holds no nested copy of a dependency the workspace declares:
7
+ // `node_modules/<a>/node_modules/<name>` or `apps/<app>/node_modules/<name>` next to the hoisted `node_modules/<name>`.
8
+ import { found, readJson } from './read.mjs';
9
+
10
+ export const DEP_VERSION_SKEW = 'HFS_DEP_VERSION_SKEW';
11
+ const SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
12
+ const LINK = /^(?:file|link|workspace|portal):/;
13
+ const NESTED = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/;
14
+ const HOISTED = /^node_modules\/((?:@[^/]+\/)?[^/]+)$/;
15
+
16
+ /** Every package.json a repository tracks: the root and each workspace's. */
17
+ const manifestsOf = (files) => files.filter((file) => file === 'package.json' || (file.endsWith('/package.json') && !file.includes('node_modules/')));
18
+
19
+ /** The findings of R14 over the tracked paths `files` of `repoRoot`. */
20
+ export function depFindings({ repoRoot, files }) {
21
+ const findings = [];
22
+ const specs = new Map();
23
+ for (const file of manifestsOf(files)) {
24
+ const pkg = readJson(repoRoot, file);
25
+ if (!pkg) continue;
26
+ for (const section of SECTIONS) {
27
+ for (const [name, spec] of Object.entries(pkg[section] ?? {})) {
28
+ if (typeof spec !== 'string' || LINK.test(spec)) continue;
29
+ if (!specs.has(name)) specs.set(name, new Map());
30
+ const at = specs.get(name);
31
+ if (!at.has(spec)) at.set(spec, []);
32
+ at.get(spec).push(file);
33
+ }
34
+ }
35
+ }
36
+ for (const [name, bySpec] of specs) {
37
+ if (bySpec.size < 2) continue;
38
+ const list = [...bySpec].map(([spec, where]) => `${spec} (${where.join(', ')})`);
39
+ findings.push(found(DEP_VERSION_SKEW, [...bySpec.values()][0][0], `${name} is declared at ${bySpec.size} versions in the workspace: ${list.join('; ')}; keep one`, { dependency: name, versions: [...bySpec.keys()] }));
40
+ }
41
+ const overrides = readJson(repoRoot, 'package.json')?.overrides;
42
+ for (const [name, pin] of Object.entries(overrides && typeof overrides === 'object' ? overrides : {})) {
43
+ if (typeof pin !== 'string' || pin.startsWith('$') || !specs.has(name)) continue;
44
+ const off = [...specs.get(name)].filter(([spec]) => spec !== pin);
45
+ if (off.length) findings.push(found(DEP_VERSION_SKEW, off[0][1][0], `${name} is pinned to ${pin} by the root overrides but declared at ${off.map(([spec, where]) => `${spec} (${where.join(', ')})`).join('; ')}; declare the pinned version`, { dependency: name, versions: off.map(([spec]) => spec), pinned: pin }));
46
+ }
47
+ const lock = files.includes('package-lock.json') ? readJson(repoRoot, 'package-lock.json') : null;
48
+ if (lock?.packages) {
49
+ const declared = new Set(specs.keys());
50
+ const hoisted = new Map();
51
+ for (const [key, entry] of Object.entries(lock.packages)) {
52
+ const top = HOISTED.exec(key);
53
+ if (top && !entry.link) hoisted.set(top[1], entry.version);
54
+ }
55
+ for (const [key, entry] of Object.entries(lock.packages)) {
56
+ const nested = NESTED.exec(key);
57
+ if (!nested || entry.link || HOISTED.test(key) || !declared.has(nested[1])) continue;
58
+ const name = nested[1];
59
+ findings.push(found(DEP_VERSION_SKEW, 'package-lock.json', `${key} is a nested copy of ${name}${entry.version ? ` ${entry.version}` : ''}${hoisted.has(name) ? ` next to the hoisted ${hoisted.get(name)}` : ''}; the workspace keeps one copy (align the ranges or add a root override)`, { dependency: name, lockPath: key, version: entry.version }));
60
+ }
61
+ }
62
+ return findings;
63
+ }