@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.1 - 2026-10-01
4
+
5
+ - Fixed: `hfs sync` validates the whole hfs.json declaration against the manifest before rendering (a pre-2.0 `connections` list of names is refused), so a pin bump can never leave eslint unable to start.
6
+ - Changed: the back-end managed scripts carry `test:stack` (`starci-test-stack`); repo-specific devDependencies stay the repository's (sync owns only the `scripts` block and the canon pins).
7
+
8
+ ## 2.0.0 - 2026-09-30
9
+
10
+ - Added (lane PORT): the repository scripts of the product repos are gone, so their laws live here. `repo.scripts` owns `scripts/{.gitkeep,*.mjs,*.cjs,*.ps1,*.sh}` (operational scripts, or an empty folder with `.gitkeep`, which `hfs sync --init` writes from `templates/{be,fe}/skeleton/scripts/.gitkeep`). New checks: `BE_SPEC_PLACEMENT` (R102, a spec outside the four test layers, `scripts/` and `tools/` included), `HFS_REPO_LOCAL_CHECK` (R103, a `check-*` file, `eslint-local-rules*`, a local plugin, a script that runs one), `HFS_LINT_SUPPRESSION_FILE` (R104, an eslint suppressions file, script or flag), `HFS_PROOF_COMMAND_FILE_MISSING` (R105, a `.starciwork` proof command that runs a file the repository does not hold; only records of this repository are judged) and, in the architecture machine, `FE_I18N_KEYS` (R106, a key the source reads that a locale lacks, a catalog key nothing reads). `HFS_DEP_VERSION_SKEW` (R14) also refuses a dependency declared at another version than the root `overrides` pin.
11
+ - Changed (lane PORT): `hfs work-hygiene` is the secrets guard of the commit for both profiles: every staged file, read from the index, is judged by the one secret judgement of `hfs check` (`secretFileFindings`: a secret by being, an `.enc` that is no sops envelope, a line that matches a secret pattern); the front-end pre-commit hook runs it too, and it runs first in both hooks. There is no override (the old `ALLOW_SECRET_SCAN`, the plaintext twin re-encryption and the `.enc` twin sync are gone).
12
+
13
+ - Changed: the back-end skeleton (`hfs sync --init`) follows the unit standard. It writes no spec but `*.service.spec.ts` (the specs of the controller, config, errors, logging and composition are gone), the health door is thin (`LiveController` dispatches one `CheckLivenessQuery` on the injected query bus, `CheckLivenessHandler.process` is one `return this.liveness.check()`, the liveness logic lives in `domain/liveness/liveness.service.ts` with its spec at 100 percent), and the platform gains `clock` (the one reader of the ambient time), `composition` (`injector`), `cqrs` (`ICQRSHandler`, the bus decorators) and `logging` decorators and log events; role-suffixed file names (`env-source.config.ts`, `domain.error.ts`, `logging.log-events.ts`, `json-logger.service.ts`). A repository lists `@nestjs/cqrs` in its dependencies and `tslib` in its devDependencies; `@nestjs/terminus` is no longer needed by the skeleton.
14
+ - Added: `hfs new service <dir> <name> [--inject ...]` and `hfs new spec <file>.service.ts` (`scaffold/service.mjs`): a back-end `*.service.ts` is created together with its `<name>.service.spec.ts` skeleton, built with `Test.createTestingModule({ providers }).compile()` and `moduleRef.get(Service)`. The constructor is read with the repository's TypeScript compiler API; each `@Inject*()` token and each class-typed parameter becomes one provider, its double chosen from `ruleParams.be.specDoubles` of the slot manifest (the table of the lint law `spec-infra-double-from-kit`), and every public method gets one placeholder `it`. The target must be owned by a slot; nothing is ever overwritten.
15
+ - Changed: Sonar no longer depends on coverage (unit test standard rule 6). The managed `sonar-project.properties` drops `sonar.coverage.exclusions` and `sonar.javascript.lcov.reportPaths`, `knowledge/sonar-gate.yaml` has no coverage condition (the gate keeps duplication, blocker and critical issues, hotspots, and zero open imported issues), and the managed CI workflows drop the Codecov upload and its `id-token: write` permission. `codecov.yml` is no longer a managed file (slot `repo.quality-config` is `sonar-project.properties` only) and `sync` no longer asks the installed preset for `sonarCoverageExclusions`. The managed back-end `test` script is `jest --selectProjects unit --coverage` (fails below the per-file 100 threshold), and a new `test:affected` (`jest --selectProjects unit --passWithNoTests`, no coverage) is what the pre-commit and pre-push hooks run over related or changed specs, because a per-file threshold judged on a partial run would fail every service the run did not touch; the front-end `test:ci` runs `vitest run` without coverage.
16
+ - Changed: a front end has no tests by standard (owner 2026-09-30). `hfs sync` no longer renders anything for a front-end test: no `vitest.config.ts`, `tsconfig.e2e.json`, `.github/workflows/e2e.yml` or `codecov.yml`; the front-end `package.json` scripts have no `test`, `test:ci`, `test:affected`, `typecheck:e2e` or `test:e2e`; the husky hooks and `ci.yml` run no test step and no codecov upload; `sonar-project.properties` carries no `sonar.tests`, `sonar.test.inclusions`, coverage exclusions or lcov path; the root `tsconfig.json` excludes only `node_modules`; `hfs sync --init` writes no spec and no vitest config; `loadPresets` needs no preset for a front end (`@starci/vitest-preset` and `@starci/playwright-preset` are deleted). Slots: `fe.e2e`, `fe.e2e-support` and the FE test roles are deleted, `fe.tool-config-repo` is `turbo.json` only, `codecov.yml` is a back-end slot (`be.codecov`). Added: `FE_NO_TESTS` (R97), the one front-end enforcer: any spec, e2e file, test directory, test-tool file, test script or test dependency is a finding, with no exception; R66 `FE_E2E_SHAPE` and R67 `FE_SPEC_QUALITY` are retired (rule ids increase and a retired id is never reused).
17
+
18
+ - Changed: R47 `test-world-files` (`BE_TEST_TOPOLOGY`) judges the world against the repository's own stack: `fakes/<provider>/` may not fake a service `.starcistacks/<env>` declares (name, image repository or a well-known alias: postgres, redis, keycloak, a mail host, ...). One exception: a stateless GPU or external-model service that `src/tests/world/test-world.config.ts` (the test-world library's declaration; its `stacks` name the environments) declares in `fakedBy` with a non-empty `reason`; a stateful kind or a service with a persistent volume is never accepted, and a `fakedBy` entry naming a missing stack service or fake folder is refused. The stack reader is `scripts/lib/stack-services.mjs`.
19
+
20
+ - Added: `hfs emit-contracts [--repo <dir>]` and the managed script `contract:emit`: writes `contracts/<app>/schema.graphql` of every api app that serves GraphQL as `printSchema(lexicographicSortSchema(schema))`. What an app serves is read from its source: the module graph is walked from `AppModule` of `apps/<app>/src/app.module.ts` through every import (imports of imports, `register`/`forRoot`/`forRootAsync` dynamic modules, `forwardRef`, spreads, both branches of a conditional), the `GraphQLModule` `include` list is honored, and an app whose graph holds no GraphQL server is skipped; only the resolver classes of the served modules are loaded, compiled the way `tsc` compiles them (decorator metadata comes from the type checker), and Nest's `GraphQLSchemaFactory` builds the schema, so it needs no environment, database or network and never boots the app. A dependency of a resolver that cannot load where the emit runs is stood in for and listed; a graph or option the reader cannot decide is an error naming the file. TypeScript, Nest and graphql are the repository's own. The rendered `.prettierignore` skips `contracts/`.
21
+ - Added: `hfs emit-contracts` also writes `contracts/<app>/openapi.json` (OpenAPI 3.1) for an api app whose `apps/<app>/src/operations.ts` exports the typed operation table `OPERATIONS` (canon BE-OPERATIONS-1): one `POST /operations` route whose body and answer are a oneOf over the operations, each with `x-operation: "<name>@<version>"`, the input, the output and the Outcome (`ok` value or declared refusal code); the schemas are read from the TypeScript checker, sorted, deterministic, and `any`/`unknown`/`Record<string, unknown>`/unbound generics/`string` refusal codes are errors naming the operation. The full `hfs check` compares it with the committed file (R23), and an operation table without a committed `openapi.json` is a finding.
22
+ - Changed: `hfs emit-contracts` folds only what literals decide: `if`/ternary/`&&`/`||`/`??` over the literal options an app passes (`register({ features })`, const-bound literals, spreads of consts, `list.push(...)`, early `return`) are followed to the branch they select; a condition it cannot decide (an env value, a call result, a parameter with no literal, an unknown list) is kept as an undecided choice and is an error, naming file, line and condition, unless every branch serves the same resolvers. Loops/switch/try that return or change a list, and objects or server options with an unknown spread, are errors.
23
+ - Added: the full `hfs check` (never `--fast`, which says so in its coverage line) emits every back-end api app into a temp directory and compares its hash with the committed `contracts/<app>/schema.graphql`; a mismatch is `HFS_CONTRACT_SNAPSHOT_DRIFT` naming both shas and `npm run contract:emit`, and an emit that cannot run is the same code with the emit error.
24
+
25
+ - Added: a front end's managed configuration, by the same mechanism as a back end (`fe.tool-config`, `fe.package-manifest`, forbidden `fe.tool-config-local`; `fe.thin-config` and the mixed `repo.package-manifest` are gone). `hfs sync` renders `tsconfig.json` (`@starci/tsconfig/next.json`), the one-line `eslint.config.mjs`, the one-line `stylelint.config.mjs` (`loadAppTokens(import.meta.url)`), `.prettierrc`, `.prettierignore` and the `scripts` block of `package.json` (`lint:check` = one ESLint run plus stylelint over `{apps,packages}/*/src/**/*.css`; `dev:<app>` and `start:<app>` per app). `turbo.json` is the repository's own (`fe.tool-config-repo`).
26
+ - Changed: the front-end hooks and workflow: pre-commit runs eslint, stylelint and prettier on the staged files (no lint-staged), pre-push adds `format:check` and `hfs:check -- --fast`, CI runs `hfs:report`, `lint:check` (with stylelint), `format:check`, `typecheck` and `build`.
27
+ - Changed: `hfs check` judges both profiles: `HFS_RULE_OFF_WITHOUT_REPLACEMENT` also for `stylelint.config.mjs`, `HFS_TS_STRICT` for the front end's root tsconfig, `HFS_TOOL_CONFIG_LOCAL` also for a stylelint plugin, stylelint flags and a tool configuration key of a `package.json`.
28
+ - Changed: one converter for both linters, `hfs report <eslint|stylelint> <in> <out>`; `hfs report-stylelint` is removed and `sonar.eslint.reportPaths` is no longer rendered (Sonar's own ESLint import drops issues on files outside `sonar.sources`). ESLint and stylelint findings on a file Sonar does not index are filed on the first source file with the real path in the message, like the HFS findings. `sonar.externalIssuesReportPaths` lists `reports/{hfs,eslint,stylelint}.sonar.json`; a front end's `sonar.sources` and `sonar.tests` include `packages` when hfs.json opts into a package slot, and its `sonar.typescript.tsconfigPaths` gains `packages/*/tsconfig.json`.
29
+ - Changed: the front-end skeleton (`hfs sync --init`): a one-app repository keeps its next-intl stack and its one API client in the app; a repository with two or more apps writes them once as `packages/<project>-i18n` (`createAppI18n`, `./proxy`, `./request`) and `packages/<project>-api` (`request`, `Outcome`) and keeps thin adapters in each app, and `--init` refuses it unless hfs.json opts into `fe.package.i18n` and `fe.package.api`. Every app also gets a `vitest.config.ts`; the shell reads `modules/i18n` through its index.
30
+ - Changed: the machine no longer treats a `lint:e2e` command as running e2e (linting is not running), and its root-entry allowlist accepts `stylelint.config.mjs`.
31
+
32
+ - Changed: `hfs check` runs the whole architecture machine after its slot, pin and size checks. Its violations and errors are findings under the machine's own rule ids, each with the Vietnamese why of its code, and any of them exits 1. The package carries a byte copy of the machine and every file it imports, plus the failure-code slice of every code the machine can emit. The machine loads `typescript` from the checked repository; a repository without it fails with `ARCH_TYPESCRIPT_MISSING`.
33
+ - Added: `hfs check --fast [--base <ref>]`, the flag the template pre-push hook already called. It judges the owners changed since the merge-base with `origin/main` (else `main`), skipping clones, dead exports and the file-system tree checks. With no merge-base it is a refusal (exit 2) that names the fix.
34
+ - Added: `HFS_EMPTY_DIR`, `HFS_GHOST_TREE` (an empty directory beside a sibling within two edits of its name) and `HFS_UNTRACKED_ROOT_ENTRY` (entries git neither tracks nor ignores, outside an `ignored` slot): rule R03 ships as an `hfs` enforcer.
35
+ - Changed: the machine's root-entry allowlist accepts `.prettierignore`, which the slot manifest already requires.
36
+ - Changed: one code per finding. `HFS_PATH_NO_SLOT` is `HFS_SLOT_UNDECLARED` and `HFS_REQUIRED_MISSING` is `HFS_SLOT_REQUIRED_MISSING` (the rule catalog's names); `HFS_STACKS_PLAINTEXT` of `hfs work-hygiene` is `HFS_PLAINTEXT_SECRET`. The old names are gone.
37
+ - Added: the owed `hfs` enforcers ship, each with a violating and a passing spec: `HFS_PLAINTEXT_SECRET` (R06), `HFS_STACKS_SHAPE` (R10), `HFS_CI_MISSING_CANON` (R13), `HFS_DEP_VERSION_SKEW` (R14), `HFS_CONTRACT_SNAPSHOT_DRIFT` (R23), `BE_TEST_TOPOLOGY` (R47), `FE_WIRE_GENERATED` (R52), `FE_I18N_PLACEMENT` (R59) and `FE_I18N_CATALOG` (R60) in `hfs check`; `HFS_GITIGNORE_BLOCK_DRIFT` (R04) and `HFS_SONAR_CONFIG` (R11) by comparing the file with its render; `HFS_FORMAT` (R19) by running the repository's own prettier (not under `--fast`; a repository without prettier is a refusal, `HFS_FORMAT_TOOL_MISSING`).
38
+ - Changed: a plaintext secret file the slot manifest forbids (`.env`, `*.pem`, ...) is one `HFS_PLAINTEXT_SECRET` finding, no longer also `HFS_FORBIDDEN_PRESENT`.
39
+ - Changed: the rendered `sonar-project.properties` carries no `sonar.host.url` (the host comes from the CI variable and the stack declaration).
40
+ - Changed: `hfs check` needs the repository's installed jest / vitest preset (the coverage exclusions of the render) and prettier; without either it refuses (exit 2) instead of skipping the check.
41
+ - Changed: owner test layout 2026-09-30. The tests' own `src/tests/tsconfig.json` (extends the root config and `@starci/tsconfig/e2e.json`) replaces `src/tests/e2e/tsconfig.json`; the managed root `tsconfig.json` excludes `src/tests/world`, `integration`, `e2e` and `contract` (fixtures stay in the root program). Scripts `test:integration`, `test:e2e`, `test:contract` (each `npm run typecheck:tests && jest --selectProjects <name>`) and `typecheck:tests` replace `typecheck:e2e` and `test:e2e:live`. `HFS_E2E_IN_AUTOMATIC_GATE` keeps integration, e2e and contract out of hooks, coverage and automatic CI. A slot forbids the retired `src/tests/e2e/world/` (goes to `src/tests/world/`); a spec whose suffix disagrees with its folder matches no slot.
42
+
43
+ - Added: `hfs sync` renders a back end's whole tool configuration from templates and `hfs check` compares it. The managed files are the slots of `slots.yaml` that name `managedBy` (each lists literal files; `managedBy` names the template directory `templates/<profile|common>/<managedBy>/`): `tsconfig.json` (extends `@starci/tsconfig/be.json`, adds the three aliases), `tsconfig.build.json`, `src/tests/tsconfig.json`, the one-line `eslint.config.mjs`, `jest.config.js`, `.prettierrc`, `.prettierignore`, and the `scripts` block of `package.json` (`lint`, `lint:check`, `typecheck`, `typecheck:tests`, `test`, `test:integration`, `test:e2e`, `test:contract`, `format`, `format:check`, `hfs:check`, `build`, one `start:<app>` per runnable app and `migrate`), compared as parsed JSON. Only the `.gitignore` block and `.starciwork/.gitignore` remain listed in code.
44
+ - Added: `hfs check` reports `HFS_MANAGED_FILE_DRIFT` (R05), `HFS_RULE_OFF_WITHOUT_REPLACEMENT` (R17, the eslint one-liner), `HFS_TOOL_CONFIG_LOCAL` (R16, tool configs outside the managed set, local rule files, flags that swap the configuration) and `HFS_TS_STRICT` (R22, by flag). The bundle no longer holds the templates; the check that renders them lives in `sync/`.
45
+ - Changed: the back-end hooks and workflows call only managed scripts: pre-commit runs `eslint` and `prettier --check` on the staged files instead of lint-staged, CI runs `npm run hfs:check` (the second `hfs sync --check` step is gone), `format:check` and `npm test -- --coverage --ci`; pre-push adds `format:check`. The front-end templates lose the `hfs sync --check` CI step.
46
+ - Changed: templates moved under `templates/<profile|common>/<managedBy>/`; `validateHfs` compares `hfs` with the manifest major (it wrongly demanded 2); a back-end app needs a `kind`.
47
+ - Changed: the machine's root-entry allowlist accepts `.prettierrc`.
48
+ - Changed: slots. `be.tool-config` (managed), `be.tool-config-local` (forbidden), `be.package-manifest`, `be.lockfile`, `be.nest-cli` and `fe.thin-config` replace the mixed `repo.tool-config` and `repo.package-manifest`; `repo.tool-config` keeps `.editorconfig` and `.nvmrc`. `.sops.yaml` no longer claims a `managedBy` nothing renders.
49
+
50
+ - Added: one Sonar mechanism for back ends and front ends. `hfs check --sonar <file>` writes the error findings as a Sonar Generic Issue Import document (engine `starci-hfs`, rule id = the finding code, catalog English and Vietnamese text, deterministic), and `hfs report-stylelint <in> <out>` converts stylelint's json output into the same format (`report/sonar.mjs`). The managed `sonar-project.properties` sets `sonar.eslint.reportPaths` and `sonar.externalIssuesReportPaths` and no longer sets `sonar.host.url` (R11); the managed CI workflows produce `reports/eslint.json`, `reports/hfs.sonar.json` (and the stylelint reports for a front end) and run the Sonar scan and gate with `!cancelled()`; a back end's scripts gain `lint:report` and `hfs:report`; `reports/` is an ignored slot.
51
+ - Added: `HFS_SONAR_CONFIG` (R11) ships as an `hfs` enforcer: `sonar-project.properties` differs from its render, or the stack declaration names another quality gate than the bundled `knowledge/sonar-gate.yaml`. The R20 and R21 Sonar enforcers ship as conditions of that gate file (`overall`: 0 open issues, duplicated lines density, S3776).
52
+ - Changed: every finding of `hfs check --json` carries the catalog's English `title` beside `titleVi`.
53
+
3
54
  ## 1.0.1 - 2026-09-30
4
55
 
5
56
  - Fixed: `package.json` now has `exports` for `./runtime/*` and `./package.json`, so other published packages can resolve the runtime copy it carries (`@starci/hfs/runtime/knowledge/hfs/slots.yaml`, `@starci/hfs/runtime/engine/yaml.mjs`) with `import.meta.resolve` or `require.resolve`, wherever the package is installed. `@starci/eslint-canon-be` 1.7.1 reads the slot manifest this way.
package/README.md CHANGED
@@ -2,41 +2,139 @@
2
2
 
3
3
  The HFS command line of a StarCi product repository. It is installed from the npm registry at the exact version in `knowledge/hfs/canon-pins.yaml` (see [`packages/README.md`](../README.md)),
4
4
  and it is self-contained: `runtime/` carries the slot manifest, the canon pins, the Vietnamese why
5
- catalog slice and the loader, so it runs where there is no runtime checkout.
5
+ catalog slice, the loader and the architecture machine with every file it imports, so it runs where there is no runtime
6
+ checkout. The machine loads `typescript` from the repository it checks (never its own copy), so run `npm ci` first.
6
7
 
7
8
  ```sh
8
- npx hfs check [--repo <dir>] [--json] # exit 1 on any error-level finding
9
+ npx hfs check [--repo <dir>] [--json] [--fast] [--base <ref>] [--sonar <file>] # exit 1 on any error-level finding
10
+ npx hfs report <eslint|stylelint> <in> <out> [--repo <dir>] # a linter's json -> Sonar Generic Issue Import (see Sonar)
9
11
  npx hfs init [--repo <dir>] [--stdout] # write a starter hfs.json (never overwrites); --stdout only prints
12
+ npx hfs emit-contracts [--repo <dir>] # write contracts/<app>/schema.graphql of every api app that serves GraphQL and contracts/<app>/openapi.json of every api app with a typed operation table (the managed script contract:emit)
10
13
  npx hfs explain <path> [--repo <dir>] [--json]
11
- npx hfs sync (--check | --write) [--root <dir>] # generated files: husky, CI, .gitignore block, sonar, codecov (sync/, templates/)
12
- npx hfs work-hygiene # pre-commit guard for staged .starciwork / .starcistacks paths
14
+ npx hfs sync (--check | --write) [--root <dir>] # generated files: the managedBy slots of slots.yaml, the .gitignore block (sync/, templates/)
15
+ npx hfs work-hygiene # pre-commit guard: staged .starciwork / .starcistacks paths, and the secrets guard over every staged file (read from the index)
16
+ npx hfs new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--repo <dir>] # a back-end service and its unit spec skeleton
17
+ npx hfs new spec <file>.service.ts [--repo <dir>] # the spec skeleton of an existing service
13
18
  ```
14
19
 
15
- `hfs check` reads the repository's `hfs.json` and the tracked paths (`git ls-files`), and reports, each with a why code and its
16
- Vietnamese text (`modules/kernel/failure-codes.yaml`):
20
+ ## Creating a service
21
+
22
+ Only `*.service.ts` files are unit-tested (unit test standard), each with exactly one colocated `<name>.service.spec.ts`. `hfs new` is the one way they come into being together:
23
+
24
+ - `hfs new service src/modules/domain/commission commission --inject InjectPrimaryEntityManager=@modules/platform/database:EntityManager --inject InjectClock=@modules/platform/clock:Clock` writes `commission.service.ts` (the class, `@Injectable()`, the constructor with the given `@Inject*()` parameters) and `commission.service.spec.ts`. `--inject` is `<Decorator>=<module>:<Type>` for a custom `@Inject*()` decorator (its token is the UPPER_SNAKE of the name, `PRIMARY_ENTITY_MANAGER`, exported from the same module) or `<Class>=<module>` for a class-typed dependency. The directory must belong to a slot of the manifest that owns the file (a service lives in `src/modules/{domain,platform,integrations}/<capability>/`), and `hfs new` never overwrites.
25
+ - `hfs new spec src/modules/domain/member/member-profile.service.ts` writes only the spec of a service you wrote by hand. It reads the constructor with the repository's own TypeScript compiler API, so `npm ci` comes first.
26
+
27
+ The spec skeleton is `Test.createTestingModule({ providers: [Service, { provide: TOKEN, useValue: double }, ...] }).compile()` and `moduleRef.get(Service)`, with one provider per constructor dependency and nothing else, every double imported from `@starci/jest-preset` (the root), and one placeholder `it` per public method. The double of a token comes from `ruleParams.be.specDoubles` of the slot manifest, the table the lint law `spec-infra-double-from-kit` holds a spec to (`*_ENTITY_MANAGER` -> `mockEntityManager()`, `CLOCK` -> `new FakeClock(...)`, `OUTBOX` -> `recordingOutbox()`, `CACHE` -> `fakeCache(clock)`, lock, lease, fence and hold -> `fakeLock(clock)`, ids -> `fakeIds()`, `*_OPTIONS` -> a literal to fill in, everything else and every class -> `mock<T>()`), so the skeleton satisfies the law by construction: no cast, no `new` of the service, no ambient clock. A back end only (a front end has no services); the files are written for prettier (print width 120).
28
+
29
+ `hfs check` reads the repository's `hfs.json` and the tracked paths (`git ls-files`), checks the work tree, and then runs the
30
+ whole architecture machine over the repository. Every finding carries a why code and its Vietnamese text
31
+ (`modules/kernel/failure-codes.yaml`).
32
+
33
+ Its own checks (`scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-rules/`; the rendered-file checks (`sync/managed.mjs`) and the prettier check (`sync/format.mjs`) live in `sync/` because they read the templates and the repository's own install, and reach `hfs check` as `extraFindings`; the jest / vitest preset the coverage exclusions come from and prettier are read from the repository's `node_modules`, so run `npm ci` first):
17
34
 
18
35
  | Code | Level | Meaning |
19
36
  |---|---|---|
20
- | `HFS_PATH_NO_SLOT` | error | a tracked path no slot owns (the nearest slot is named) |
37
+ | `HFS_SLOT_UNDECLARED` | error | a tracked path no slot owns (the nearest slot is named) |
21
38
  | `HFS_SLOT_NOT_ENABLED` | error | a path in an opt-in slot `hfs.json` did not declare |
22
39
  | `HFS_SLOT_AMBIGUOUS` | error | two slots own the path equally (a manifest gap) |
23
40
  | `HFS_TRACKED_MUST_BE_IGNORED` | error | a tracked path in an `ignored` slot (build output, generated) |
24
41
  | `HFS_FORBIDDEN_PRESENT` | error | a tracked path in a forbidden / `external` slot |
25
- | `HFS_REQUIRED_MISSING` | error | a file or directory a required slot, app or instance must contain |
42
+ | `HFS_SLOT_REQUIRED_MISSING` | error | a file or directory a required slot, app or instance must contain |
26
43
  | `HFS_MIN_INSTANCES` | error | fewer instances of a slot than `minInstances` |
27
44
  | `HFS_CANON_PIN_DRIFT` | error | a dependency not at the exact version of `knowledge/hfs/canon-pins.yaml` |
28
45
  | `HFS_SIZE_SOFT_BACKLOG` | info | a source file over `ruleParams.fileLines.soft`; report only, never fails |
46
+ | `HFS_MANAGED_FILE_DRIFT` | error | a managed file that exists but differs from its render (hooks, workflows, sonar, `tsconfig.build.json`, `src/tests/tsconfig.json` (back end), `jest.config.js` (back end), `.prettierrc`, `.prettierignore`, the `scripts` block of `package.json`, compared as parsed JSON) |
47
+ | `HFS_RULE_OFF_WITHOUT_REPLACEMENT` | error | `eslint.config.mjs`, or a front end's `stylelint.config.mjs`, differs from its one-line render, so a rule could be off, warned or redefined in it |
48
+ | `HFS_TOOL_CONFIG_LOCAL` | error | a repository holds a tool config outside the managed set (`.eslintrc*`, `.eslintignore`, a second `eslint.config.*` or `stylelint.config.*`, another prettier or lint-staged config, or a jest config in a back end that is not the one root file), a file that defines an ESLint rule or a stylelint plugin, a tool configuration key in a `package.json` (`eslintConfig`, `stylelint`, `prettier`, `lint-staged`, `jest`), or a script that runs eslint, stylelint or prettier with a flag that swaps the configuration |
49
+ | `HFS_SONAR_CONFIG` | error | `sonar-project.properties` differs from its render (no host URL; the ESLint report and HFS import paths), or the stack declaration names another quality gate than the one of `knowledge/sonar-gate.yaml` |
50
+ | `HFS_TS_STRICT` | error | the root `tsconfig.json` sets, lowers or adds anything but `extends` the preset (`be.json`, `next.json`), the template's `exclude` and (back end) the three `paths`; the finding names the flag |
51
+ | `HFS_EMPTY_DIR` | error | a directory with no file below it (git tracks none), outside `.git`, `node_modules` and `ignored` slots; the topmost one is reported |
52
+ | `HFS_GHOST_TREE` | error | an empty directory beside a sibling whose name is within two edits of its own (`business` / `bussiness`) |
53
+ | `HFS_UNTRACKED_ROOT_ENTRY` | error | an entry git neither tracks nor ignores (`git ls-files -o --exclude-standard`), outside an `ignored` slot |
54
+ | `HFS_PLAINTEXT_SECRET` | error | a tracked plaintext secret: an env, key or credentials file, a value the push scan refuses (never printed), or an `.enc` that is no sops envelope (R06) |
55
+ | `HFS_STACKS_SHAPE` | error | a `.starcistacks` path outside the standard shape (a sealed file outside `<env>/secrets/`, `runtime/files/`, root `DESIGN.md` or `k8s/`), a local Sonar not owned by the host, a service still rooted at `.stacks` (R10) |
56
+ | `HFS_CI_MISSING_CANON` | error | `ci.yml` without a `run: npx hfs check` step (whole check, pinned version), or `.husky/pre-push` without `npm run typecheck` and `npm run lint:check` (R13) |
57
+ | `HFS_DEP_VERSION_SKEW` | error | a dependency at two specs across the root and workspace `package.json` files, a dependency declared at another version than the root `overrides` pin, or a nested copy of a declared dependency in `package-lock.json` (R14) |
58
+ | `HFS_CONTRACT_SNAPSHOT_DRIFT` | error / info | a back end serving GraphQL without `contracts/<app>/schema.graphql`, a front-end contract copy that differs by hash from the sibling back end named by `hfs.json` `stacks` (info when the sibling is not checked out) (R23) |
59
+ | `BE_TEST_TOPOLOGY` | error | a `*.test.*` file, a `testing/` folder, a second jest configuration or a `jest` key in `package.json` (R47; `int-spec`, `harness-spec` and the retired test folders are the machine's `HFS_TEST_KIND_RETIRED`) |
60
+ | `BE_SPEC_PLACEMENT` | error | a `*.spec.*`, `*.test.*` or `*-spec.*` file outside the four test layers, `scripts/` and `tools/` included (R102) |
61
+ | `HFS_REPO_LOCAL_CHECK` | error | a `check-*` file in `scripts/` or `tools/`, an `eslint-local-rules*` file or local eslint plugin, or a script that runs a local check (R103) |
62
+ | `HFS_LINT_SUPPRESSION_FILE` | error | an `eslint.suppressions*` file, a `lint:suppressions` script, an eslint suppress flag or a suppressions config (R104) |
63
+ | `HFS_PROOF_COMMAND_FILE_MISSING` | error | a `.starciwork` `requiresProof.<kind>.command` that runs a file the repository does not hold (R105) |
64
+ | `FE_WIRE_GENERATED` | error | a contract copy with no `codegen` script wired before `build` and `typecheck`, or generated types older than the copy (R52) |
65
+ | `FE_I18N_PLACEMENT` | error | no `next-intl`, no `src/proxy.ts`, a `middleware.ts`, a route file outside `[locale]`, no `vi.json` catalog (R59) |
66
+ | `FE_I18N_CATALOG` | error | a locale catalog lacking a key another locale has (R60) |
67
+ | `FE_I18N_KEYS` | error | a literal key read through `next-intl` that a locale lacks, or a catalog key no source reads (R106; the architecture machine) |
68
+ | `FE_NO_TESTS` | error | a front end holds a `*.spec.*`, `*.test.*` or `*-spec.*` file, an `e2e/`, `__tests__/`, `__mocks__/` or `test-support/` directory, a vitest, Playwright, jest or Cypress file, a test script, or a test dependency in a `package.json`; no exception (R97; `scripts/lib/hfs-rules/fe-no-tests.mjs`) |
69
+ | `HFS_GITIGNORE_BLOCK_DRIFT` | error | the managed `.gitignore` block differs from its render (R04; `sync/managed.mjs`) |
70
+ | `HFS_SONAR_CONFIG` | error | `sonar-project.properties` differs from its render: no `sonar.host.url`, the `sonar.exclusions` of the installed jest preset, no coverage import (R11; `sync/managed.mjs`) |
71
+ | `HFS_FORMAT` | error | a tracked file the repository's own prettier would change (R19; `sync/format.mjs`, not under `--fast`) |
72
+ | `HFS_FORMAT_TOOL_MISSING` | refusal (exit 2) | prettier is not installed in the repository; the format check is never skipped |
29
73
 
30
- Exit codes: 0 clean, 1 an error finding, 2 a refusal (not a Git work tree, bad flag). The command never writes to the repository
74
+ The managed files are judged by `sync/managed.mjs` and `sync/ts-strict.mjs` (the package renders the templates; the runtime copy does not). The list of managed
75
+ files is the `managedBy` slots of `slots.yaml`; `hfs sync --write` renders them and `hfs check` compares them, so a hand edit and a forgotten `sync` are the same finding.
76
+ Each finding is reported once: the eslint and stylelint one-liners under R17, `tsconfig.json` under R22 when it names a flag, a workflow or hook that lost a canon step under R13 (or R19 for the format step), everything else under R05.
77
+ A front end is rendered by the same mechanism as a back end: `tsconfig.json`, `eslint.config.mjs`, `stylelint.config.mjs`, `.prettierrc`, `.prettierignore`, the hooks, the workflow, the Sonar file
78
+ and the `scripts` block (`lint:check` is the one lint gate, ESLint over the repository plus stylelint; a front end has no test script, no test configuration and no e2e or coverage file: it has no tests, and `FE_NO_TESTS` (R97) refuses any spec, e2e file, test tool, test script or test dependency, `scripts/` included). `turbo.json` stays the repository's own
79
+ (`fe.tool-config-repo`): it carries the repository's task graph, which no preset can render.
80
+
81
+ The architecture machine (`scripts/checks/architecture.mjs` of the runtime, the same code bundled here): tiers and import
82
+ direction, owner public API, cycles, module registration and composition, clones, dead exports, required files, size growth,
83
+ the source-shape, contract-form and front-end rules, and the repository-tree rules. Each violation and each error is one
84
+ finding under the machine's own rule id (`BE_TIER_DIRECTION`, `HFS_UNUSED_EXPORT`, `ARCH_OWNER_EXPORT_BYPASS`, ...). A
85
+ repository without `typescript` installed fails with `ARCH_TYPESCRIPT_MISSING`; the check never passes because it could not run.
86
+
87
+ `--fast` (the template pre-push hook runs `npx hfs check --fast`) judges only what changed since the merge-base of `HEAD` with
88
+ `origin/main` (else `main`; `--base <ref>` names another ref): the slot and pin checks on the changed paths, the machine on the owners
89
+ of the changed source files without clones and dead exports, and no file-system tree checks. The required-file and minimum-instance
90
+ checks still cover the whole tree. With no merge-base `--fast` is a refusal (exit 2) that names the fix, never a silent full
91
+ pass. Without `--fast`, `--base <ref>` is the base of the size-growth check.
92
+
93
+ Exit codes: 0 clean, 1 an error finding, 2 a refusal (not a Git work tree, bad flag, `--fast` with no merge-base). The command never writes to the repository
31
94
  except `hfs init`, which writes `hfs.json` only when none exists.
32
95
 
96
+ ## Sonar
97
+
98
+ One mechanism, the same for a back end and a front end: every finding of the canon is imported into Sonar, and the quality gate
99
+ fails while any is open. Nothing is configured per repository; the pieces are managed files (`hfs sync`) and this package.
100
+
101
+ | Source | Report | How Sonar reads it |
102
+ |---|---|---|
103
+ | `hfs check` (repository, managed-file and architecture-machine findings) | `reports/hfs.sonar.json`, from `hfs check --sonar reports/hfs.sonar.json` | `sonar.externalIssuesReportPaths`, engine `starci-hfs`, rule id = the finding code |
104
+ | ESLint (the BE and FE canon plugins alike) | `reports/eslint.json` (`npm run lint:report`) then `reports/eslint.sonar.json`, from `hfs report eslint reports/eslint.json reports/eslint.sonar.json` | `sonar.externalIssuesReportPaths`, engine `eslint`, rule id = the ESLint rule |
105
+ | stylelint (front end) | `reports/stylelint.json` (`npm run lint:report:css`) then `reports/stylelint.sonar.json`, from `hfs report stylelint ...` | `sonar.externalIssuesReportPaths`, engine `stylelint`, rule id = the stylelint rule |
106
+
107
+ `--sonar` writes the error findings as a Generic Issue Import document (SonarQube 10.3+ format: `{ rules, issues }`) before the verdict, so a
108
+ failing check still leaves its report. A rule's name and description are the catalog's English title and Vietnamese title, meaning and next step;
109
+ impacts are HIGH (maintainability). `info` findings (the soft-size backlog) are report-only and not imported. The output is sorted, so two runs
110
+ over one tree are byte-identical. Sonar drops an issue on a file it does not index (a tracked source file or stylesheet under `sonar.sources`), so the ONE placement rule of the three engines files a finding on
111
+ any other path (hfs.json, a workflow, a config file, `e2e/`, a package the sources do not list) and a finding with no path on the first source file, its message starting with the real path.
112
+ Sonar's own ESLint import (`sonar.eslint.reportPaths`) is not used: it drops those issues silently. A front end's `sonar.sources` and `sonar.tests` are `apps`, plus `packages` when hfs.json opts into `repo.packages` or an `fe.package.*` slot.
113
+
114
+ The managed `sonar-project.properties` carries `sonar.externalIssuesReportPaths` (`reports/hfs.sonar.json`, `reports/eslint.sonar.json` and, for a front end, `reports/stylelint.sonar.json`) and no `sonar.host.url` (the host is
115
+ `SONAR_HOST_URL`); the managed CI workflow produces the reports and runs the scan and the gate action with `!cancelled()`, so a failed check step still
116
+ reaches Sonar while the job stays failed. There is no `continue-on-error`. Both profiles run `npm run hfs:report`, `npm run lint:report` (a front end also `npm run lint:report:css`) and `npx hfs report <linter> <in> <out>` for each. The duplicate-block threshold (`ruleParams.<profile>.duplicateBlock`) has no Sonar property for TypeScript (SonarJS detects
117
+ clones with its own token rule), so the machine enforces it (R21) and its findings are imported like every other.
118
+
119
+ The gate is `knowledge/sonar-gate.yaml`, the one declaration: the new-code conditions (duplication, blocker and critical issues, hotspots; no coverage condition) and an `overall` part (0 open issues on the whole code,
120
+ duplicated lines density, cognitive complexity through the S3776 rule). A SonarQube gate condition cannot filter by engine, so the condition counts every
121
+ open issue, imported or native; that is stricter than the three imports alone and is intended. `hfs check` reports `HFS_SONAR_CONFIG` (R11) when the
122
+ properties file is not its render or the stack declaration names another gate. R20 and R21 have their Sonar enforcers as conditions of that file.
123
+
124
+ Sonar does not depend on coverage: the managed properties file has no `sonar.*.lcov.reportPaths` and no `sonar.coverage.*`, the gate has no coverage condition, and the managed CI workflow uploads no coverage anywhere (no Codecov). A back end's unit coverage is the runner's: the managed `test` script is `jest --selectProjects unit --coverage`, which fails below the per-file 100 threshold on `src/**/*.service.ts`.
125
+
33
126
  ## Maintaining the bundle
34
127
 
35
- `runtime/` is a byte copy of the runtime files listed in `scripts/sync-runtime.mjs` (plus the catalog slice of the codes the
36
- check can emit). After changing `scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-slots.mjs`, `knowledge/hfs/slots.yaml`,
37
- `knowledge/hfs/canon-pins.yaml` or the catalog entries of those codes, run `node packages/hfs/scripts/sync-runtime.mjs`;
128
+ `runtime/` is a byte copy of the slot loader files, the pins, and the import closure of `scripts/lib/hfs-check.mjs` and
129
+ `scripts/checks/architecture.mjs` (computed by `scripts/sync-runtime.mjs`, so a new import of the machine is bundled without
130
+ editing a list), plus the catalog slice of every code `hfs check` can emit: its own and the machine's (`ARCHITECTURE_RULE_IDS`,
131
+ derived from the machine's rule id lists). After changing any of those files, `knowledge/hfs/slots.yaml`,
132
+ `knowledge/hfs/canon-pins.yaml`, `knowledge/patterns/fe/folder.yaml` or the catalog entries of those codes, run `node packages/hfs/scripts/sync-runtime.mjs`;
38
133
  `tests/hfs-cli.spec.mjs` fails on a stale copy. Bump `version` here and in the pin when the behaviour changes.
39
134
 
135
+ The examples gate `node scripts/checks/check-example-architecture.mjs` runs this CLI (full check) on every `examples/*` directory
136
+ with an `hfs.json` and fails on any error-level finding (it is heavy: run it once, by hand).
137
+
40
138
  ## Serving knowledge to other packages
41
139
 
42
140
  `package.json` `exports` opens `./runtime/*`, so a package that needs a runtime file resolves it from the installed copy
package/bin/hfs.mjs CHANGED
@@ -1,35 +1,76 @@
1
1
  #!/usr/bin/env node
2
2
  // hfs - the HFS command line of a StarCi product repository.
3
- // hfs check [--repo <dir>] [--json] every tracked path has a slot; required files exist; nothing forbidden or
4
- // tracked-that-must-be-ignored; pins match; soft-size backlog (report only).
5
- // Exit 1 on any error-level finding.
3
+ // hfs check [--repo <dir>] [--json] [--fast] [--base <ref>] [--sonar <file>]
4
+ // every tracked path has a slot; required files exist; nothing forbidden or
5
+ // tracked-that-must-be-ignored; pins match; every managed file equals its render
6
+ // (sync/managed.mjs, the .gitignore block and sonar-project.properties included); no empty or ghost
7
+ // directory, no untracked entry outside an ignored slot; plaintext secrets, the .starcistacks shape, CI
8
+ // and pre-push canon steps, dependency version skew, the contract snapshot and the test, wire and i18n
9
+ // trees; prettier over every tracked file through the repository's own install (sync/format.mjs; not under
10
+ // --fast, and a repository without prettier is a refusal); soft-size backlog (report only); then the whole
11
+ // architecture machine (tiers, owners, clones, dead exports, module registration, the
12
+ // front-end and back-end source rules), each violation a finding with its why.
13
+ // --fast: only what changed since the merge-base with origin/main (else main; --base
14
+ // overrides it, and without --fast is the base of the size-growth check); the machine
15
+ // runs on those owners without clones and dead exports. No merge-base is a refusal
16
+ // (exit 2), never a silent full pass. Exit 1 on any error-level finding.
17
+ // --sonar: also write the error findings as a Sonar Generic Issue Import file (report/sonar.mjs),
18
+ // before the verdict, so a failing check still leaves the report Sonar imports.
19
+ // hfs report <eslint|stylelint> <in> <out> [--repo <dir>] convert a linter's json output into a Sonar Generic Issue Import file
20
+ // (one converter for both linters; a finding on a file Sonar does not index is filed on the first
21
+ // source file of sonar.sources, the real path in its message).
6
22
  // hfs init [--repo <dir>] [--stdout] write a starter hfs.json by detecting the profile and the apps.
7
23
  // hfs explain <path> [--repo <dir>] [--json] which slot owns the path, its tier, allowed imports, required tests.
8
- // hfs sync (--check | --write) [--root <dir>] the generated files (husky, CI, .gitignore block, sonar, codecov); sync/cli.mjs
9
- // hfs work-hygiene the pre-commit guard for staged .starciwork and .starcistacks paths; sync/cli.mjs
24
+ // hfs emit-contracts [--repo <dir>] write contracts/<app>/schema.graphql of every api app that serves GraphQL (emit/contracts.mjs):
25
+ // printSchema(lexicographicSortSchema) of the resolvers the app root composes; no env, no database, no network.
26
+ // hfs sync (--check | --write) [--root <dir>] the generated files (husky, CI, .gitignore block, sonar); sync/cli.mjs
27
+ // hfs work-hygiene the pre-commit guard: staged .starciwork and .starcistacks paths, and the secrets guard over every staged file (read from the index); sync/cli.mjs
28
+ // hfs new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--repo <dir>]
29
+ // a back-end `<name>.service.ts` and its `<name>.service.spec.ts` skeleton (scaffold/service.mjs): the spec is built
30
+ // with Test.createTestingModule, one provider per constructor dependency (kit doubles from @starci/jest-preset),
31
+ // one placeholder it per public method. Never overwrites a file.
32
+ // hfs new spec <file>.service.ts [--repo <dir>] the spec skeleton of an existing service, read from its constructor with the repository's TypeScript
10
33
  // Every finding names a why code and carries its Vietnamese text. The command reads the repository, never writes to it
11
34
  // (init writes hfs.json only, and only when none exists). Exit codes: 0 clean, 1 error findings, 2 a refusal or bad usage.
35
+ import fs from 'node:fs';
12
36
  import path from 'node:path';
13
37
  import { fileURLToPath } from 'node:url';
14
- import { checkRepo, explainPath, initRepo } from '../runtime/scripts/lib/hfs-check.mjs';
38
+ import { checkRepository, explainPath, initRepo, trackedFiles } from '../runtime/scripts/lib/hfs-check.mjs';
15
39
  import { HfsSlotsError } from '../runtime/scripts/lib/hfs-slots.mjs';
40
+ import { formatFindings } from '../sync/format.mjs';
16
41
  import { main as syncMain } from '../sync/cli.mjs';
42
+ import { SyncError } from '../sync/index.mjs';
43
+ import { managedFindings } from '../sync/managed.mjs';
44
+ import { emitContracts } from '../emit/contracts.mjs';
45
+ import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
46
+ import { contractEmitFindings } from '../runtime/scripts/lib/hfs-rules/contract.mjs';
47
+ import { LINTER_KINDS, convertReportFile, sonarReport, sourceRootsOf, writeReport } from '../report/sonar.mjs';
17
48
 
18
- const USAGE = `hfs check [--repo <dir>] [--json]
49
+ const USAGE = `hfs check [--repo <dir>] [--json] [--fast] [--base <ref>] [--sonar <file>]
50
+ hfs report <eslint|stylelint> <in> <out> [--repo <dir>]
19
51
  hfs init [--repo <dir>] [--stdout]
52
+ hfs emit-contracts [--repo <dir>]
20
53
  hfs explain <path> [--repo <dir>] [--json]
21
54
  hfs sync (--check | --write) [--root <dir>]
22
55
  hfs work-hygiene
56
+ hfs new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--repo <dir>]
57
+ hfs new spec <file>.service.ts [--repo <dir>]
23
58
  `;
24
59
  const PER_CODE_LIMIT = 25;
25
- const VALUE_FLAGS = new Set(['--repo']);
26
- const BOOL_FLAGS = new Set(['--json', '--stdout']);
60
+ const VALUE_FLAGS = new Set(['--repo', '--base', '--sonar', '--inject']);
61
+ /** Flags that may repeat: their values are collected in order. */
62
+ const LIST_FLAGS = new Set(['--inject']);
63
+ const BOOL_FLAGS = new Set(['--json', '--stdout', '--fast']);
27
64
 
28
65
  function parse(argv) {
29
66
  const opts = { positional: [] };
30
67
  for (let i = 0; i < argv.length; i += 1) {
31
68
  const arg = argv[i];
32
- if (VALUE_FLAGS.has(arg)) { opts[arg.slice(2)] = argv[i + 1]; i += 1; if (opts[arg.slice(2)] === undefined) throw new Error(`${arg} needs a value`); }
69
+ if (VALUE_FLAGS.has(arg)) {
70
+ if (argv[i + 1] === undefined) throw new Error(`${arg} needs a value`);
71
+ opts[arg.slice(2)] = LIST_FLAGS.has(arg) ? [...(opts[arg.slice(2)] ?? []), argv[i + 1]] : argv[i + 1];
72
+ i += 1;
73
+ }
33
74
  else if (BOOL_FLAGS.has(arg)) opts[arg.slice(2)] = true;
34
75
  else if (arg.startsWith('--')) throw new Error(`unknown flag ${arg}`);
35
76
  else opts.positional.push(arg);
@@ -39,7 +80,11 @@ function parse(argv) {
39
80
 
40
81
  function printCheck(result, out) {
41
82
  const { counts } = result;
42
- out(`hfs check ${result.repoRoot} (profile ${result.profile ?? 'unknown'}, manifest ${result.manifest}, ${result.tracked} tracked paths)\n`);
83
+ out(`hfs check${result.fast ? ' --fast' : ''} ${result.repoRoot} (profile ${result.profile ?? 'unknown'}, manifest ${result.manifest}, ${result.tracked} tracked paths)\n`);
84
+ if (result.fast) out(` ${result.fast.changed} path${result.fast.changed === 1 ? '' : 's'} changed since ${result.fast.base.slice(0, 12)}\n`);
85
+ out(` architecture machine: ${result.machine.status === 'ran' ? `ran over ${result.machine.files} source files${result.machine.paths ? ` (owners ${result.machine.paths.join(', ')})` : ''}` : `skipped, ${result.machine.reason}`}\n`);
86
+ if (result.contracts) out(` contract snapshots: ${result.contracts.status === 'checked' ? `emitted and compared, ${result.contracts.apps.map((a) => `${a.app}/${a.artifact} ${a.status}`).join(', ') || 'no api app'}` : `skipped, ${result.contracts.reason}`}
87
+ `);
43
88
  const byCode = new Map();
44
89
  for (const f of result.findings) byCode.set(f.code, [...(byCode.get(f.code) ?? []), f]);
45
90
  for (const [code, list] of byCode) {
@@ -51,6 +96,13 @@ function printCheck(result, out) {
51
96
  out(`\n${counts.error} error finding${counts.error === 1 ? '' : 's'}, ${counts.info} report-only\n`);
52
97
  }
53
98
 
99
+ /** The check's error findings as the Sonar import file: findings outside `sonar.sources` are filed on the first source file. */
100
+ function writeSonarReport({ repoRoot, file, result }) {
101
+ let properties = '';
102
+ try { properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8'); } catch { /* no properties: every finding keeps its own path */ }
103
+ writeReport(file, sonarReport(result.findings, { sourceRoots: sourceRootsOf(properties), tracked: trackedFiles(repoRoot) }));
104
+ }
105
+
54
106
  function printExplain(e, out) {
55
107
  out(`${e.path}\n`);
56
108
  if (e.status === 'no-slot') {
@@ -68,19 +120,71 @@ function printExplain(e, out) {
68
120
  if (e.code) out(` ${e.code}: ${e.titleVi}\n ${e.whyVi}\n`);
69
121
  }
70
122
 
71
- export async function main(argv, { stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s) } = {}) {
123
+ /** `presets` and `prettier` are test seams: the Sonar exclusions sync would load from the repository's installed preset, and the repository's own prettier. */
124
+ export async function main(argv, { stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier } = {}) {
72
125
  const [verb, ...rest] = argv;
73
- if (!['check', 'init', 'explain', 'sync', 'work-hygiene'].includes(verb)) { stderr(USAGE); return 2; }
126
+ if (!['check', 'init', 'explain', 'sync', 'work-hygiene', 'report', 'emit-contracts', 'new'].includes(verb)) { stderr(USAGE); return 2; }
74
127
  try {
75
128
  if (verb === 'sync' || verb === 'work-hygiene') return await syncMain(argv);
76
129
  const opts = parse(rest);
77
130
  const repoRoot = path.resolve(opts.repo ?? process.cwd());
78
131
  if (verb === 'check') {
79
132
  if (opts.positional.length) throw new Error('hfs check takes no path');
80
- const result = checkRepo({ repoRoot });
133
+ const tracked = trackedFiles(repoRoot);
134
+ // Managed files, the .gitignore block and sonar against their render (R04, R05, R11, ...), and prettier through the repository's own install (R19, never under --fast).
135
+ const extraFindings = [...await managedFindings({ repoRoot, tracked, presets }), ...(opts.fast === true ? [] : await formatFindings({ repoRoot, files: tracked, prettier }))];
136
+ // R23 against the app itself (full pass only): the committed snapshots equal what `emit-contracts` writes now.
137
+ let contracts = null;
138
+ let declared = null;
139
+ try { declared = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8')); } catch { /* checkRepository reports the unreadable declaration */ }
140
+ if (declared?.profile === 'be') {
141
+ if (opts.fast === true) contracts = { status: 'skipped', reason: '--fast does not emit the apps' };
142
+ else {
143
+ const emitted = contractEmitFindings({ repoRoot, files: tracked, repo: declared, emit: emitContracts });
144
+ extraFindings.push(...emitted.findings);
145
+ contracts = { status: 'checked', apps: emitted.apps };
146
+ }
147
+ }
148
+ const result = checkRepository({ repoRoot, fast: opts.fast === true, base: opts.base, extraFindings });
149
+ if (contracts) result.contracts = contracts;
150
+ if (opts.sonar !== undefined) writeSonarReport({ repoRoot, file: path.resolve(opts.sonar), result });
81
151
  if (opts.json) stdout(`${JSON.stringify(result, null, 2)}\n`); else printCheck(result, stdout);
82
152
  return result.ok ? 0 : 1;
83
153
  }
154
+ if (verb === 'report') {
155
+ if (opts.positional.length !== 3 || !LINTER_KINDS.includes(opts.positional[0])) throw new Error(`hfs report takes a linter (${LINTER_KINDS.join(' or ')}), an input and an output file`);
156
+ const [kind, input, output] = opts.positional;
157
+ let properties = '';
158
+ try { properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8'); } catch { /* no properties: every finding keeps its own path */ }
159
+ const sourceRoots = sourceRootsOf(properties);
160
+ const issues = convertReportFile({ kind, input: path.resolve(input), output: path.resolve(output), root: repoRoot, sourceRoots, tracked: sourceRoots.length ? trackedFiles(repoRoot) : [] });
161
+ stdout(`hfs report ${kind}: ${issues} issue${issues === 1 ? '' : 's'} written to ${output}\n`);
162
+ return 0;
163
+ }
164
+ if (verb === 'emit-contracts') {
165
+ if (opts.positional.length) throw new Error('hfs emit-contracts takes no path');
166
+ const declaration = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8'));
167
+ const { written, skipped, standIns } = emitContracts({ repoRoot, declaration });
168
+ for (const file of written) stdout(`wrote ${file}
169
+ `);
170
+ stdout(`hfs emit-contracts: ${written.length} written${skipped.length ? `, ${skipped.join(', ')} serve no GraphQL` : ''}
171
+ `);
172
+ for (const [app, lines] of Object.entries(standIns)) {
173
+ stdout(`${app}: ${lines.length} dependenc${lines.length === 1 ? 'y' : 'ies'} of the resolvers could not load here and stood in (no GraphQL type is affected):\n`);
174
+ for (const line of lines) stdout(` ${line}\n`);
175
+ }
176
+ return 0;
177
+ }
178
+ if (verb === 'new') {
179
+ const [kind, ...args] = opts.positional;
180
+ let written;
181
+ if (kind === 'service' && args.length === 2) written = newService({ repoRoot, dir: args[0], name: args[1], inject: opts.inject ?? [] });
182
+ else if (kind === 'spec' && args.length === 1 && opts.inject === undefined) written = newSpec({ repoRoot, file: args[0] });
183
+ else throw new Error('hfs new takes `service <dir> <name> [--inject ...]` or `spec <file>.service.ts`');
184
+ for (const file of written) stdout(`created ${file}
185
+ `);
186
+ return 0;
187
+ }
84
188
  if (verb === 'init') {
85
189
  if (opts.positional.length) throw new Error('hfs init takes no path');
86
190
  const result = initRepo({ repoRoot, write: !opts.stdout });
@@ -92,7 +196,7 @@ export async function main(argv, { stdout = (s) => process.stdout.write(s), stde
92
196
  if (opts.json) stdout(`${JSON.stringify(explained, null, 2)}\n`); else printExplain(explained, stdout);
93
197
  return explained.status === 'no-slot' || explained.status === 'ambiguous' ? 1 : 0;
94
198
  } catch (error) {
95
- stderr(error instanceof HfsSlotsError ? `${error.message}\n` : `hfs: ${error.message}\n${USAGE}`);
199
+ stderr(error instanceof HfsSlotsError || error instanceof SyncError ? `${error.message}\n` : error instanceof ScaffoldError ? `${error.code}: ${error.message}\n` : `hfs: ${error.message}\n${USAGE}`);
96
200
  return 2;
97
201
  }
98
202
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The compiler options both emit workers use: the repository's own tsconfig.json, made emit-safe, with the two decorator switches
3
+ * Nest needs. A configuration that cannot be read completely (an `extends` package that is not installed here) falls back to what
4
+ * a Nest project compiles with, and each problem is handed to `note` so the caller can report it like a stand-in.
5
+ */
6
+ import path from 'node:path';
7
+
8
+ /** Answers the compiler options of the repository at `repoRoot`. */
9
+ export function compilerOptionsOf(ts, repoRoot, note = () => {}) {
10
+ const parsed = ts.getParsedCommandLineOfConfigFile(
11
+ path.join(repoRoot, 'tsconfig.json'),
12
+ {},
13
+ { ...ts.sys, onUnRecoverableConfigFileDiagnostic: (diagnostic) => { throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')); } },
14
+ );
15
+ for (const error of parsed.errors) note(`tsconfig.json (${ts.flattenDiagnosticMessageText(error.messageText, ' ')}): compiler options fall back to the Nest defaults`);
16
+ return {
17
+ target: ts.ScriptTarget.ES2022,
18
+ module: ts.ModuleKind.CommonJS,
19
+ esModuleInterop: true,
20
+ ...parsed.options,
21
+ experimentalDecorators: true,
22
+ emitDecoratorMetadata: true,
23
+ declaration: false,
24
+ declarationMap: false,
25
+ sourceMap: false,
26
+ inlineSourceMap: false,
27
+ incremental: false,
28
+ composite: false,
29
+ tsBuildInfoFile: undefined,
30
+ noEmit: false,
31
+ noEmitOnError: false,
32
+ outDir: undefined,
33
+ rootDir: undefined,
34
+ };
35
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `hfs emit-contracts`: writes `contracts/<app>/schema.graphql` for every api app of hfs.json that serves GraphQL, and
3
+ * `contracts/<app>/openapi.json` for every api app whose `apps/<app>/src/operations.ts` exports the typed operation table
4
+ * `OPERATIONS` (operations.mjs: OpenAPI 3.1 read from the TypeScript checker; nothing is executed).
5
+ *
6
+ * The snapshot is `printSchema(lexicographicSortSchema(schema))`, nothing else, so it is deterministic and never carries a
7
+ * generator banner. It needs no environment, no database and no network, and never boots the app.
8
+ *
9
+ * What an app serves is decided from its source (`static-graph.mjs`): the module graph is walked from `AppModule` of
10
+ * `apps/<app>/src/app.module.ts` through every import (imports of imports, `register`/`forRoot`/`forRootAsync` dynamic modules,
11
+ * `forwardRef`, spreads and both branches of a conditional), and the resolver providers of the modules the GraphQL server reads
12
+ * (all of them, or the `include` whitelist and its imports) are the resolvers of the contract. The schema itself comes from
13
+ * Nest's own `GraphQLSchemaFactory` (the builder the running server uses) over those classes, compiled the way `tsc` compiles
14
+ * them. An app whose graph holds no GraphQL server is skipped; an app whose graph cannot be decided is an error.
15
+ *
16
+ * TypeScript, Nest and graphql are the repository's own packages; nothing is added to the managed devDependencies. Every app
17
+ * runs in its own child process (`schema-worker.mjs`) because Nest GraphQL's type metadata is process-global.
18
+ */
19
+ import { spawnSync } from 'node:child_process';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { openapiPath } from './operations.mjs';
24
+
25
+ /** The stderr prefix of a dependency the worker could not load and stood in for. */
26
+ export const STAND_IN = 'stand-in ';
27
+ const WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'schema-worker.mjs');
28
+ const OPERATIONS_WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'operations-worker.mjs');
29
+
30
+ /** The repository-relative path of an app's root module. */
31
+ export const appModulePath = (app) => `apps/${app}/src/app.module.ts`;
32
+
33
+ /** The repository-relative path of an app's snapshot. */
34
+ export const snapshotPath = (app) => `contracts/${app}/schema.graphql`;
35
+
36
+ /** The `paths` aliases of a tsconfig as [{ prefix, targets }], `@modules/*` giving prefix `@modules/`. Pure. */
37
+ export function aliasesOf(paths, baseDir) {
38
+ return Object.entries(paths ?? {})
39
+ .filter(([pattern]) => pattern.endsWith('/*'))
40
+ .map(([pattern, targets]) => ({ prefix: pattern.slice(0, -1), targets: targets.map((target) => path.resolve(baseDir, target.replace(/\*$/, ''))) }));
41
+ }
42
+
43
+ /** The absolute base a specifier resolves to through the aliases, or null when it is not aliased. Pure. */
44
+ export function aliasTarget(aliases, specifier) {
45
+ const alias = aliases.find((item) => specifier.startsWith(item.prefix));
46
+ return alias ? path.join(alias.targets[0], specifier.slice(alias.prefix.length)) : null;
47
+ }
48
+
49
+ /** The api apps of a declaration that can serve GraphQL: kind `api`. Pure. */
50
+ export const apiApps = (declaration) => (declaration.apps ?? []).filter((app) => app.kind === 'api').map((app) => app.name);
51
+
52
+ /** The text written to a snapshot file: the printed schema and one final newline. Pure. */
53
+ export const snapshotText = (printed) => `${printed.replace(/\n+$/, '')}\n`;
54
+
55
+ /** Runs one worker of one app; answers its stdout, or null when the app has nothing of that kind (exit 3). */
56
+ function runWorker(worker, repoRoot, app) {
57
+ const result = spawnSync(process.execPath, [worker, repoRoot, app], { encoding: 'utf8', cwd: repoRoot, env: { PATH: process.env.PATH ?? '' }, maxBuffer: 256 * 1024 * 1024 });
58
+ if (result.status === 3) return { text: null, lines: [] };
59
+ const lines = (result.stderr ?? '').split(/\r?\n/).filter(Boolean);
60
+ if (result.status !== 0) throw new Error(`hfs emit-contracts: ${app} failed (exit ${result.status}): ${(lines.join('\n') || result.stdout).trim()}`);
61
+ return { text: result.stdout, lines: lines.filter((line) => line.startsWith(STAND_IN)) };
62
+ }
63
+
64
+ /** Emits the GraphQL schema and the operations of one app: `{ graphql, openapi, standIns }`, each text or null. */
65
+ function emitApp(repoRoot, app) {
66
+ const graphql = runWorker(WORKER, repoRoot, app);
67
+ const operations = runWorker(OPERATIONS_WORKER, repoRoot, app);
68
+ return { graphql: graphql.text, openapi: operations.text, standIns: [...graphql.lines, ...operations.lines] };
69
+ }
70
+
71
+ /**
72
+ * Writes the snapshots of every api app: `contracts/<app>/schema.graphql` when it serves GraphQL and `contracts/<app>/openapi.json`
73
+ * when it has an operation table. Answers `{ written: [paths], skipped: [apps], standIns: { app: [lines] } }`, `skipped` being the
74
+ * apps with neither. `declaration` is the parsed hfs.json. `outDir` (absolute) redirects the snapshots to another root, keeping
75
+ * their relative paths.
76
+ */
77
+ export function emitContracts({ repoRoot, declaration, outDir = repoRoot }) {
78
+ const written = [];
79
+ const skipped = [];
80
+ const standIns = {};
81
+ for (const app of apiApps(declaration)) {
82
+ const emitted = emitApp(repoRoot, app);
83
+ if (emitted.graphql === null && emitted.openapi === null) {
84
+ skipped.push(app);
85
+ continue;
86
+ }
87
+ for (const [text, relative] of [[emitted.graphql, snapshotPath(app)], [emitted.openapi, openapiPath(app)]]) {
88
+ if (text === null) continue;
89
+ const target = path.join(outDir, relative);
90
+ fs.mkdirSync(path.dirname(target), { recursive: true });
91
+ fs.writeFileSync(target, snapshotText(text));
92
+ written.push(relative);
93
+ }
94
+ if (emitted.standIns.length) standIns[app] = emitted.standIns;
95
+ }
96
+ return { written, skipped, standIns };
97
+ }
@@ -0,0 +1,24 @@
1
+ // The child process of `hfs emit-contracts` for the OPERATIONS of ONE app: `node operations-worker.mjs <repoRoot> <app>`.
2
+ // Prints the OpenAPI 3.1 document of the app's typed operation table on stdout; exit 3 when the app has no `apps/<app>/src/operations.ts`.
3
+ // Loads the repository's own typescript (resolved from the repository, never from hfs). Nothing of the repository is executed.
4
+ import { createRequire } from 'node:module';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { compilerOptionsOf } from './compiler.mjs';
8
+ import { openApiText, operationsPath, readOperations } from './operations.mjs';
9
+
10
+ const [rootArgument, app] = process.argv.slice(2);
11
+ const repoRoot = path.resolve(rootArgument);
12
+ const file = path.join(repoRoot, operationsPath(app));
13
+ if (!fs.existsSync(file)) process.exit(3);
14
+ const ts = createRequire(path.join(repoRoot, 'package.json'))('typescript');
15
+ const options = compilerOptionsOf(ts, repoRoot, (message) => process.stderr.write(`stand-in ${message}\n`));
16
+ const program = ts.createProgram({ rootNames: [file], options });
17
+ try {
18
+ const { operations, components } = readOperations({ ts, program, file });
19
+ process.stdout.write(openApiText({ app, operations, components }));
20
+ } catch (error) {
21
+ process.stderr.write(`${error.message}
22
+ `);
23
+ process.exit(1);
24
+ }