@starci/hfs 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +110 -12
  3. package/bin/hfs.mjs +119 -15
  4. package/emit/compiler.mjs +35 -0
  5. package/emit/contracts.mjs +97 -0
  6. package/emit/operations-worker.mjs +24 -0
  7. package/emit/operations.mjs +126 -0
  8. package/emit/schema-worker.mjs +117 -0
  9. package/emit/static-graph.mjs +670 -0
  10. package/emit/type-schema.mjs +145 -0
  11. package/package.json +4 -1
  12. package/report/sonar.mjs +180 -0
  13. package/runtime/engine/admission.mjs +284 -0
  14. package/runtime/engine/digest.mjs +10 -0
  15. package/runtime/engine/ledger-db.mjs +1245 -0
  16. package/runtime/engine/machine-db.mjs +1484 -0
  17. package/runtime/engine/migrations/machine/0001-init.sql +887 -0
  18. package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
  19. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
  20. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
  21. package/runtime/engine/plain-object.mjs +5 -0
  22. package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
  23. package/runtime/knowledge/hfs/slots.yaml +405 -137
  24. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  25. package/runtime/knowledge/sonar-gate.yaml +85 -0
  26. package/runtime/modules/kernel/failure-codes.yaml +1480 -16
  27. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  28. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  29. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  30. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  31. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  32. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  33. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  34. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  35. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  36. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  37. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  38. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  39. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  40. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  41. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  42. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  43. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  44. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  45. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  46. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  47. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  48. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  49. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  50. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  51. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  52. package/runtime/scripts/checks/architecture/index.mjs +316 -0
  53. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  54. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  55. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  56. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  57. package/runtime/scripts/checks/architecture/owners.mjs +89 -0
  58. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  59. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  60. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  61. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  62. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  63. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  64. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  65. package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
  66. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  67. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  68. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  69. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  70. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  71. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  72. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  73. package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
  74. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  75. package/runtime/scripts/checks/architecture.mjs +41 -0
  76. package/runtime/scripts/checks/common.mjs +37 -0
  77. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  78. package/runtime/scripts/lib/artifact-hold.mjs +89 -0
  79. package/runtime/scripts/lib/artifact-store.mjs +103 -0
  80. package/runtime/scripts/lib/fs-kind.mjs +10 -0
  81. package/runtime/scripts/lib/git.mjs +53 -0
  82. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  83. package/runtime/scripts/lib/hfs-check.mjs +254 -28
  84. package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
  85. package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
  86. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  87. package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
  88. package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
  89. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
  90. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
  91. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  92. package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
  93. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  94. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
  95. package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +88 -40
  98. package/runtime/scripts/lib/hfs-tree.mjs +80 -0
  99. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  100. package/runtime/scripts/lib/json.mjs +22 -0
  101. package/runtime/scripts/lib/language.mjs +107 -0
  102. package/runtime/scripts/lib/path-key.mjs +2 -0
  103. package/runtime/scripts/lib/redact.mjs +148 -0
  104. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  105. package/runtime/scripts/lib/safe-remove.mjs +179 -0
  106. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  107. package/runtime/scripts/lib/sleep-sync.mjs +17 -0
  108. package/runtime/scripts/lib/stack-declaration.mjs +52 -0
  109. package/runtime/scripts/lib/stack-services.mjs +217 -0
  110. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  111. package/scaffold/service.mjs +333 -0
  112. package/sync/format.mjs +46 -0
  113. package/sync/hygiene.mjs +56 -24
  114. package/sync/index.mjs +126 -41
  115. package/sync/managed.mjs +170 -0
  116. package/sync/skeleton.mjs +32 -10
  117. package/sync/sonar-key.mjs +13 -0
  118. package/sync/ts-strict.mjs +48 -0
  119. package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
  120. package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
  121. package/templates/be/hooks/husky/pre-commit +13 -0
  122. package/templates/be/hooks/husky/pre-push +7 -0
  123. package/templates/be/package-scripts/package.json +21 -0
  124. package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
  125. package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
  126. package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
  127. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  128. package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
  129. package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
  130. package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
  131. package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
  132. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
  133. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
  134. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
  135. package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
  136. package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
  137. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
  138. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
  139. package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
  140. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
  141. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
  142. package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
  143. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
  144. package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
  145. package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
  146. package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
  147. package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
  148. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
  149. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
  150. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
  151. package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
  152. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
  153. package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
  154. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
  155. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
  156. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
  157. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
  158. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
  159. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
  162. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
  163. package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
  164. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
  165. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
  166. package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
  167. package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
  168. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
  169. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
  170. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
  171. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
  172. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
  173. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
  174. package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
  175. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
  176. package/templates/be/tool-config/eslint.config.mjs +3 -0
  177. package/templates/be/tool-config/jest.config.js +1 -0
  178. package/templates/be/tool-config/prettierignore +8 -0
  179. package/templates/be/tool-config/prettierrc +1 -0
  180. package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
  181. package/templates/be/tool-config/tsconfig.build.json +5 -0
  182. package/templates/be/tool-config/tsconfig.json +11 -0
  183. package/templates/common/gitignore.base +1 -1
  184. package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
  185. package/templates/fe/hooks/husky/pre-commit +16 -0
  186. package/templates/fe/hooks/husky/pre-push +6 -0
  187. package/templates/fe/package-scripts/package.json +17 -0
  188. package/templates/fe/parts/api-client.ts +44 -0
  189. package/templates/fe/parts/api-outcome.ts +7 -0
  190. package/templates/fe/quality-config/sonar-project.properties +8 -0
  191. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
  192. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
  193. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
  194. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  195. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
  196. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
  197. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
  198. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
  199. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
  200. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
  201. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
  202. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
  203. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
  204. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
  205. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
  206. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
  207. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
  208. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
  209. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
  210. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
  211. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
  212. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
  213. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
  214. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
  215. package/templates/fe/tool-config/eslint.config.mjs +3 -0
  216. package/templates/fe/tool-config/prettierignore +10 -0
  217. package/templates/fe/tool-config/prettierrc +1 -0
  218. package/templates/fe/tool-config/stylelint.config.mjs +3 -0
  219. package/templates/fe/tool-config/tsconfig.json +4 -0
  220. package/templates/be/pre-commit +0 -8
  221. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
  222. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
  223. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
  224. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
  225. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
  226. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
  227. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
  228. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
  229. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
  230. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
  231. package/templates/common/codecov.yml +0 -13
  232. package/templates/common/pre-push +0 -5
  233. package/templates/fe/e2e.yml +0 -22
  234. package/templates/fe/pre-commit +0 -7
  235. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
  236. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
  237. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
  238. package/templates/fe/sonar-project.properties +0 -11
  239. /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
  240. /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
  241. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
  242. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
  243. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
  244. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
  245. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
  246. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
@@ -0,0 +1,333 @@
1
+ // hfs new service | spec - the one way a `*.service.ts` and its unit spec come into being.
2
+ //
3
+ // Unit test standard (owner-locked): only `*.service.ts` files are unit-tested, each with exactly one colocated
4
+ // `<name>.service.spec.ts`, built with `Test.createTestingModule({ providers }).compile()` and `moduleRef.get(Service)`,
5
+ // with the providers equal to the constructor dependencies and every double taken from `@starci/jest-preset`. This module
6
+ // writes that spec: it reads the service constructor with the repository's own TypeScript compiler API and PRE-FILLS one
7
+ // provider per dependency, so the author starts from a spec that already compiles, provides exactly what the service asks
8
+ // for and has one placeholder `it` per public method.
9
+ //
10
+ // The token of an `Inject<Name>()` decorator is `<NAME>` (UPPER_SNAKE of the name), exported from the module the decorator is
11
+ // imported from (the capability decorators file, through its index) - the convention the `injection-token-exported` law keeps.
12
+ // The double of a token comes from `ruleParams.be.specDoubles` of the slot manifest, the same table the lint law
13
+ // `spec-infra-double-from-kit` holds a spec to, so the skeleton satisfies the law by construction.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { createRequire } from 'node:module';
17
+ import { explainPath } from '../runtime/scripts/lib/hfs-check.mjs';
18
+ import { loadSlotManifest } from '../runtime/scripts/lib/hfs-slots.mjs';
19
+
20
+ export class ScaffoldError extends Error {
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.name = 'ScaffoldError';
24
+ this.code = code;
25
+ }
26
+ }
27
+
28
+ const KIT = '@starci/jest-preset';
29
+ const CLOCK_START = '2026-01-01T00:00:00.000Z';
30
+ const PRINT_WIDTH = 120;
31
+ const INDENT = ' ';
32
+
33
+ // ------------------------------------------------------------------------------------------------ names
34
+
35
+ const words = name => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').split(/[\s_-]+/).filter(Boolean);
36
+ /** `member-profile` -> `MemberProfile`. */
37
+ export const pascalOf = name => words(name).map(word => word[0].toUpperCase() + word.slice(1).toLowerCase()).join('');
38
+ /** `InjectPrimaryEntityManager` -> `PRIMARY_ENTITY_MANAGER`. */
39
+ export const tokenNameOf = decorator => words(decorator.replace(/^Inject/, '')).map(word => word.toUpperCase()).join('_');
40
+ const camelOf = name => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
41
+
42
+ const SERVICE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
43
+
44
+ // ------------------------------------------------------------------------------------------------ reading a service
45
+
46
+ /**
47
+ * The shape of the service class of `text`: { className, dependencies, methods }.
48
+ * dependencies: in constructor order, either { kind: 'token', name, decorator, token, module, type } for an `@Inject*()` custom
49
+ * decorator (or `@Inject(TOKEN)`), or { kind: 'class', name, className, module } for a class-typed parameter.
50
+ * `type` is { text, imports: [{ name, module }] }, the parameter type and where each imported name of it comes from.
51
+ */
52
+ export function readServiceShape({ ts, fileName, text }) {
53
+ const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
54
+ const imported = new Map();
55
+ for (const statement of source.statements) {
56
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
57
+ const bindings = statement.importClause?.namedBindings;
58
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
59
+ for (const element of bindings.elements) imported.set(element.name.text, { module: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text });
60
+ }
61
+ const base = path.basename(fileName).replace(/\.service\.ts$/, '');
62
+ const classes = source.statements.filter(statement => ts.isClassDeclaration(statement) && statement.name && (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0 && statement.name.text.endsWith('Service'));
63
+ const node = classes.find(candidate => candidate.name.text === `${pascalOf(base)}Service`) ?? classes[0];
64
+ if (!node) throw new ScaffoldError('HFS_NEW_NO_SERVICE_CLASS', `${fileName} exports no class named *Service`);
65
+
66
+ const typeOf = annotation => {
67
+ const names = new Set();
68
+ const visit = child => { if (ts.isIdentifier(child) && imported.has(child.text)) names.add(child.text); ts.forEachChild(child, visit); };
69
+ visit(annotation);
70
+ return { text: annotation.getText(source), imports: [...names].map(name => ({ name, module: imported.get(name).module })) };
71
+ };
72
+
73
+ const dependencies = [];
74
+ const constructor = node.members.find(member => ts.isConstructorDeclaration(member));
75
+ for (const parameter of constructor?.parameters ?? []) {
76
+ if (!ts.isIdentifier(parameter.name) || !parameter.type) throw new ScaffoldError('HFS_NEW_UNTYPED_PARAMETER', `${fileName}: every constructor parameter needs a name and a type annotation`);
77
+ const name = parameter.name.text;
78
+ const decorator = (ts.getDecorators(parameter) ?? []).map(item => item.expression).find(expression => ts.isCallExpression(expression) && ts.isIdentifier(expression.expression));
79
+ if (decorator) {
80
+ const callee = decorator.expression.text;
81
+ if (callee === 'Inject') {
82
+ const argument = decorator.arguments[0];
83
+ if (!argument || !ts.isIdentifier(argument) || !imported.has(argument.text)) throw new ScaffoldError('HFS_NEW_TOKEN_UNKNOWN', `${fileName}: @Inject(...) of ${name} must name a token imported from a module`);
84
+ dependencies.push({ kind: 'token', name, decorator: 'Inject', token: argument.text, module: imported.get(argument.text).module, type: typeOf(parameter.type) });
85
+ continue;
86
+ }
87
+ if (callee.startsWith('Inject')) {
88
+ if (!imported.has(callee)) throw new ScaffoldError('HFS_NEW_TOKEN_UNKNOWN', `${fileName}: ${callee} is not imported, so the module that exports its token is unknown`);
89
+ dependencies.push({ kind: 'token', name, decorator: callee, token: tokenNameOf(callee), module: imported.get(callee).module, type: typeOf(parameter.type) });
90
+ continue;
91
+ }
92
+ }
93
+ const reference = ts.isTypeReferenceNode(parameter.type) && ts.isIdentifier(parameter.type.typeName) ? parameter.type.typeName.text : null;
94
+ if (!reference || !imported.has(reference)) throw new ScaffoldError('HFS_NEW_DEPENDENCY_UNKNOWN', `${fileName}: parameter ${name} is neither an @Inject*() token nor an imported class; provide it by hand`);
95
+ dependencies.push({ kind: 'class', name, className: reference, module: imported.get(reference).module });
96
+ }
97
+
98
+ const methods = node.members
99
+ .filter(member => ts.isMethodDeclaration(member) && ts.isIdentifier(member.name) && !(ts.getCombinedModifierFlags(member) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)))
100
+ .map(member => member.name.text);
101
+ return { className: node.name.text, dependencies, methods };
102
+ }
103
+
104
+ // ------------------------------------------------------------------------------------------------ writing the spec
105
+
106
+ const tableOf = manifest => {
107
+ const table = manifest.ruleParams?.be?.specDoubles;
108
+ if (!table) throw new ScaffoldError('HFS_NEW_NO_DOUBLE_TABLE', 'the slot manifest has no ruleParams.be.specDoubles: refresh @starci/hfs');
109
+ return table;
110
+ };
111
+
112
+ const doubleOf = (table, token) => table.doubles.find(entry => new RegExp(entry.token).test(token)) ?? table.fallback;
113
+
114
+ /** One line of code per double kind; `clock` is the shared FakeClock of the build helper. */
115
+ const EMITTERS = {
116
+ mockEntityManager: () => ({ code: 'mockEntityManager()', uses: ['mockEntityManager'] }),
117
+ fakeTransaction: () => ({ code: 'fakeTransaction(mockEntityManager())', uses: ['fakeTransaction', 'mockEntityManager'] }),
118
+ FakeClock: () => ({ code: 'clock', clock: true }),
119
+ recordingOutbox: () => ({ code: 'recordingOutbox()', uses: ['recordingOutbox'] }),
120
+ fakeCache: () => ({ code: 'fakeCache(clock)', uses: ['fakeCache'], clock: true }),
121
+ fakeLock: () => ({ code: 'fakeLock(clock)', uses: ['fakeLock'], clock: true }),
122
+ fakeIds: () => ({ code: 'fakeIds()', uses: ['fakeIds'] }),
123
+ builder: () => ({ code: '{}', note: 'the real values of the options this service reads: one case per flag branch' }),
124
+ mock: dependency => ({ code: `mock<${dependency.type.text}>()`, uses: ['mock'] }),
125
+ };
126
+
127
+ /** The import line of `names` from `module`, wrapped at the print width the way prettier would. */
128
+ const importLine = (names, module, typeOnly = false) => {
129
+ const head = typeOnly ? 'import type' : 'import';
130
+ const single = `${head} { ${names.join(', ')} } from "${module}"`;
131
+ return single.length <= PRINT_WIDTH ? single : `${head} {\n${names.map(name => `${INDENT}${name},`).join('\n')}\n} from "${module}"`;
132
+ };
133
+
134
+ const compareModules = (a, b) => {
135
+ const rank = module => (module.startsWith('.') ? 2 : module.startsWith('@modules/') || module.startsWith('@features/') || module.startsWith('@tests/') ? 1 : 0);
136
+ return rank(a) - rank(b) || a.localeCompare(b);
137
+ };
138
+
139
+ /** The spec skeleton of a service shape: text of `<name>.service.spec.ts`. */
140
+ export function specSkeleton({ shape, serviceFile, manifest = loadSlotManifest() }) {
141
+ const table = tableOf(manifest);
142
+ const subject = `./${path.basename(serviceFile).replace(/\.ts$/, '')}`;
143
+ const kit = new Set();
144
+ const imports = new Map();
145
+ const typeImports = new Map();
146
+ const addImport = (map, module, name) => { if (!map.has(module)) map.set(module, new Set()); map.get(module).add(name); };
147
+ let needsClock = false;
148
+
149
+ const taken = new Set(['service', 'clock', 'moduleRef']);
150
+ const variables = [];
151
+ const providers = [shape.className];
152
+ const notes = [];
153
+ for (const dependency of shape.dependencies) {
154
+ if (dependency.kind === 'class') {
155
+ addImport(imports, dependency.module, dependency.className);
156
+ const variable = uniqueName(camelOf(dependency.name), taken);
157
+ variables.push({ variable, code: `mock<${dependency.className}>()` });
158
+ kit.add('mock');
159
+ providers.push({ provide: dependency.className, variable });
160
+ continue;
161
+ }
162
+ const entry = doubleOf(table, dependency.token);
163
+ const emit = EMITTERS[entry.double];
164
+ if (!emit) throw new ScaffoldError('HFS_NEW_DOUBLE_UNKNOWN', `the slot manifest names the double ${entry.double} for ${dependency.token}, which hfs new cannot write yet`);
165
+ const made = emit(dependency);
166
+ for (const name of made.uses ?? []) kit.add(name);
167
+ if (made.clock) { needsClock = true; kit.add('FakeClock'); }
168
+ addImport(imports, dependency.module, dependency.token);
169
+ for (const type of dependency.type.imports) if (made.uses?.includes('mock')) addImport(typeImports, type.module, type.name);
170
+ if (entry.double === 'FakeClock') { providers.push({ provide: dependency.token, variable: 'clock' }); continue; }
171
+ const variable = uniqueName(camelOf(dependency.name), taken);
172
+ variables.push({ variable, code: made.code, note: made.note });
173
+ if (made.note) notes.push(made.note);
174
+ providers.push({ provide: dependency.token, variable });
175
+ }
176
+ if (shape.dependencies.some(dependency => dependency.kind === 'token' && doubleOf(table, dependency.token).double === 'FakeClock')) needsClock = true;
177
+
178
+ const lines = [];
179
+ lines.push(importLine(['Test'], '@nestjs/testing'));
180
+ if (kit.size) lines.push(importLine([...kit].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })), KIT));
181
+ const modules = [...new Set([...imports.keys(), ...typeImports.keys()])].sort(compareModules);
182
+ for (const module of modules) {
183
+ if (imports.has(module)) lines.push(importLine([...imports.get(module)].sort(), module));
184
+ if (typeImports.has(module)) lines.push(importLine([...typeImports.get(module)].sort(), module, true));
185
+ }
186
+ lines.push(`import { ${shape.className} } from "${subject}"`);
187
+ lines.push('');
188
+
189
+ const build = [];
190
+ build.push('const build = async () => {');
191
+ if (needsClock) build.push(`${INDENT}const clock = new FakeClock("${CLOCK_START}")`);
192
+ for (const item of variables) {
193
+ if (item.note) build.push(`${INDENT}// Fill in ${item.note}.`);
194
+ build.push(`${INDENT}const ${item.variable} = ${item.code}`);
195
+ }
196
+ build.push(`${INDENT}const moduleRef = await Test.createTestingModule({`);
197
+ build.push(`${INDENT}${INDENT}providers: [`);
198
+ for (const provider of providers) build.push(`${INDENT}${INDENT}${INDENT}${typeof provider === 'string' ? provider : `{ provide: ${provider.provide}, useValue: ${provider.variable} }`},`);
199
+ build.push(`${INDENT}${INDENT}],`);
200
+ build.push(`${INDENT}}).compile()`);
201
+ const returned = ['service: moduleRef.get(' + shape.className + ')', ...(needsClock ? ['clock'] : []), ...variables.map(item => item.variable)];
202
+ const single = `${INDENT}return { ${returned.join(', ')} }`;
203
+ if (single.length <= PRINT_WIDTH) build.push(single);
204
+ else build.push(`${INDENT}return {`, ...returned.map(item => `${INDENT}${INDENT}${item},`), `${INDENT}}`);
205
+ build.push('}');
206
+ lines.push(...build, '');
207
+
208
+ lines.push(`describe("${shape.className}", () => {`);
209
+ if (shape.methods.length === 0) {
210
+ lines.push(`${INDENT}it("is built by the testing module with its constructor dependencies", async () => {`);
211
+ lines.push(`${INDENT}${INDENT}const { service } = await build()`);
212
+ lines.push(`${INDENT}${INDENT}expect(service).toBeInstanceOf(${shape.className})`);
213
+ lines.push(`${INDENT}})`);
214
+ } else {
215
+ shape.methods.forEach((method, index) => {
216
+ if (index > 0) lines.push('');
217
+ lines.push(`${INDENT}describe("${method}", () => {`);
218
+ lines.push(`${INDENT}${INDENT}it("${method} has its first case written", async () => {`);
219
+ lines.push(`${INDENT}${INDENT}${INDENT}const { service } = await build()`);
220
+ lines.push(`${INDENT}${INDENT}${INDENT}expect(service.${method}).toBeInstanceOf(Function)`);
221
+ lines.push(`${INDENT}${INDENT}})`);
222
+ lines.push(`${INDENT}})`);
223
+ });
224
+ }
225
+ lines.push('})');
226
+ return `${lines.join('\n')}\n`;
227
+ }
228
+
229
+ function uniqueName(name, taken) {
230
+ let candidate = name;
231
+ for (let counter = 2; taken.has(candidate); counter += 1) candidate = `${name}${counter}`;
232
+ taken.add(candidate);
233
+ return candidate;
234
+ }
235
+
236
+ // ------------------------------------------------------------------------------------------------ writing the service
237
+
238
+ /**
239
+ * `--inject` entries: `InjectCache=@modules/integrations/cache:Cache` (a token dependency: decorator, the module that exports the
240
+ * decorator and its token, the parameter type) or `ProbeCheckerService=@modules/platform/probes` (a class dependency).
241
+ */
242
+ export function parseInject(entry) {
243
+ const match = /^([A-Za-z][A-Za-z0-9]*)=([^:\s]+)(?::([A-Za-z][A-Za-z0-9<>, ]*))?$/.exec(entry);
244
+ if (!match) throw new ScaffoldError('HFS_NEW_INJECT_INVALID', `--inject ${entry}: expected InjectName=<module>:<Type> or ClassName=<module>`);
245
+ const [, name, module, type] = match;
246
+ if (name.startsWith('Inject')) {
247
+ if (!type) throw new ScaffoldError('HFS_NEW_INJECT_INVALID', `--inject ${entry}: a token dependency names its type after a colon (InjectCache=<module>:Cache)`);
248
+ return { kind: 'token', decorator: name, module, type, parameter: camelOf(type.replace(/<.*$/, '')) };
249
+ }
250
+ if (type) throw new ScaffoldError('HFS_NEW_INJECT_INVALID', `--inject ${entry}: a class dependency takes no type`);
251
+ return { kind: 'class', className: name, module, parameter: camelOf(name) };
252
+ }
253
+
254
+ /** The text of a new `<name>.service.ts` with the given dependencies (parseInject results). */
255
+ export function serviceSource({ name, dependencies = [] }) {
256
+ const className = `${pascalOf(name)}Service`;
257
+ const values = new Map();
258
+ const types = new Map();
259
+ const add = (map, module, item) => { if (!map.has(module)) map.set(module, new Set()); map.get(module).add(item); };
260
+ add(values, '@nestjs/common', 'Injectable');
261
+ for (const dependency of dependencies) {
262
+ if (dependency.kind === 'token') { add(values, dependency.module, dependency.decorator); add(types, dependency.module, dependency.type.replace(/<.*$/, '')); } else add(values, dependency.module, dependency.className);
263
+ }
264
+ const lines = [];
265
+ for (const module of [...new Set([...values.keys(), ...types.keys()])].sort(compareModules)) {
266
+ if (values.has(module)) lines.push(importLine([...values.get(module)].sort(), module));
267
+ if (types.has(module)) lines.push(importLine([...types.get(module)].sort(), module, true));
268
+ }
269
+ lines.push('', '@Injectable()', `/** The ${words(name).join(' ')} service: state what it decides in one sentence. */`);
270
+ if (dependencies.length === 0) lines.push(`export class ${className} {}`);
271
+ else {
272
+ lines.push(`export class ${className} {`, `${INDENT}constructor(`);
273
+ for (const dependency of dependencies) lines.push(dependency.kind === 'token' ? `${INDENT}${INDENT}@${dependency.decorator}() private readonly ${dependency.parameter}: ${dependency.type},` : `${INDENT}${INDENT}private readonly ${dependency.parameter}: ${dependency.className},`);
274
+ lines.push(`${INDENT}) {}`, '}');
275
+ }
276
+ return `${lines.join('\n')}\n`;
277
+ }
278
+
279
+ // ------------------------------------------------------------------------------------------------ the commands
280
+
281
+ const loadTypeScript = repoRoot => {
282
+ try { return createRequire(path.join(repoRoot, 'package.json'))('typescript'); } catch {
283
+ throw new ScaffoldError('HFS_NEW_TYPESCRIPT_MISSING', `typescript is not installed under ${repoRoot}; run npm ci first, hfs new reads the constructor with the repository's own TypeScript`);
284
+ }
285
+ };
286
+
287
+ const profileOf = repoRoot => {
288
+ let declaration;
289
+ try { declaration = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8')); } catch { throw new ScaffoldError('HFS_NEW_NO_HFS', `${repoRoot} has no readable hfs.json`); }
290
+ if (declaration.profile !== 'be') throw new ScaffoldError('HFS_NEW_BACKEND_ONLY', 'hfs new service | spec writes back-end services; this repository is a front end');
291
+ return declaration;
292
+ };
293
+
294
+ /** The slot check of a target: the manifest must own the path, else nothing is written. */
295
+ const requireSlot = (repoRoot, relative) => {
296
+ const explained = explainPath({ repoRoot, input: relative });
297
+ if (explained.status === 'no-slot' || explained.status === 'ambiguous') throw new ScaffoldError('HFS_NEW_NO_SLOT', `${relative} is owned by no slot of the HFS manifest (${explained.status}); a service belongs in src/modules/{domain,platform,integrations}/<capability>/`);
298
+ };
299
+
300
+ const writeNew = (repoRoot, relative, text) => {
301
+ const target = path.join(repoRoot, relative);
302
+ if (fs.existsSync(target)) throw new ScaffoldError('HFS_NEW_EXISTS', `${relative} already exists; hfs new never overwrites`);
303
+ fs.mkdirSync(path.dirname(target), { recursive: true });
304
+ fs.writeFileSync(target, text);
305
+ return relative;
306
+ };
307
+
308
+ /** `hfs new spec <file>.service.ts`: writes the spec skeleton of an existing service; returns the created paths. */
309
+ export function newSpec({ repoRoot, file, ts = loadTypeScript(repoRoot), manifest = loadSlotManifest() }) {
310
+ profileOf(repoRoot);
311
+ const relative = file.split(path.sep).join('/').replace(/^\.\//, '');
312
+ if (!relative.endsWith('.service.ts')) throw new ScaffoldError('HFS_NEW_NOT_A_SERVICE', `${relative} is not a *.service.ts file: only services have a unit spec`);
313
+ const absolute = path.join(repoRoot, relative);
314
+ if (!fs.existsSync(absolute)) throw new ScaffoldError('HFS_NEW_NO_SERVICE', `${relative} does not exist`);
315
+ const specPath = relative.replace(/\.ts$/, '.spec.ts');
316
+ requireSlot(repoRoot, specPath);
317
+ const shape = readServiceShape({ ts, fileName: absolute, text: fs.readFileSync(absolute, 'utf8') });
318
+ return [writeNew(repoRoot, specPath, specSkeleton({ shape, serviceFile: relative, manifest }))];
319
+ }
320
+
321
+ /** `hfs new service <dir> <name> [--inject ...]`: writes the service and its spec skeleton; returns the created paths. */
322
+ export function newService({ repoRoot, dir, name, inject = [], ts = loadTypeScript(repoRoot), manifest = loadSlotManifest() }) {
323
+ profileOf(repoRoot);
324
+ if (!SERVICE_NAME.test(name)) throw new ScaffoldError('HFS_NEW_NAME_INVALID', `the service name ${name} must be kebab-case (member-profile), without the .service suffix`);
325
+ const relative = path.posix.join(dir.split(path.sep).join('/').replace(/^\.\//, '').replace(/\/$/, ''), `${name}.service.ts`);
326
+ requireSlot(repoRoot, relative);
327
+ requireSlot(repoRoot, relative.replace(/\.ts$/, '.spec.ts'));
328
+ for (const target of [relative, relative.replace(/\.ts$/, '.spec.ts')]) if (fs.existsSync(path.join(repoRoot, target))) throw new ScaffoldError('HFS_NEW_EXISTS', `${target} already exists; hfs new never overwrites`);
329
+ const text = serviceSource({ name, dependencies: inject.map(parseInject) });
330
+ const shape = readServiceShape({ ts, fileName: path.join(repoRoot, relative), text });
331
+ const spec = specSkeleton({ shape, serviceFile: relative, manifest });
332
+ return [writeNew(repoRoot, relative, text), writeNew(repoRoot, relative.replace(/\.ts$/, '.spec.ts'), spec)];
333
+ }
@@ -0,0 +1,46 @@
1
+ // format.mjs - HFS_FORMAT (R19): Prettier is the only formatter, and every tracked file it formats is formatted.
2
+ // The check is `prettier --check` over the tracked files through the repository's OWN prettier (resolved from the repository,
3
+ // with the repository's prettier config and .prettierignore), so it judges what the repository's pre-commit and CI judge. A
4
+ // repository with no installed prettier is a refusal (HFS_FORMAT_TOOL_MISSING), never a pass. It is slow and is not part of `--fast`.
5
+ import { createRequire } from 'node:module';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { SyncError } from './index.mjs';
9
+
10
+ export const FORMAT = 'HFS_FORMAT';
11
+ export const IGNORE_FILE = '.prettierignore';
12
+ /** Lockfiles are written by npm, not by people: they are never formatted. */
13
+ const LOCKFILES = new Set(['package-lock.json']);
14
+
15
+ /** The repository's own prettier, or a SyncError when it is not installed. */
16
+ export function loadPrettier(repoRoot) {
17
+ const require = createRequire(path.join(repoRoot, 'package.json'));
18
+ try {
19
+ return require('prettier');
20
+ } catch {
21
+ throw new SyncError('HFS_FORMAT_TOOL_MISSING', `prettier is not installed under ${repoRoot}; install the version knowledge/hfs/canon-pins.yaml pins, so the format check judges what the repository's own gates judge`);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse.
27
+ * `prettier` is a seam for specs; the default is the repository's own.
28
+ */
29
+ export async function formatFindings({ repoRoot, files, prettier = loadPrettier(repoRoot) }) {
30
+ const ignorePath = path.join(repoRoot, IGNORE_FILE);
31
+ const findings = [];
32
+ for (const file of files.filter((f) => !LOCKFILES.has(path.posix.basename(f)))) {
33
+ const absolute = path.join(repoRoot, file);
34
+ let text;
35
+ try { text = fs.readFileSync(absolute, 'utf8'); } catch { continue; }
36
+ const info = await prettier.getFileInfo(absolute, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
37
+ if (info.ignored || !info.inferredParser) continue;
38
+ const options = (await prettier.resolveConfig(absolute, { editorconfig: true, useCache: false })) ?? {};
39
+ try {
40
+ if (!(await prettier.check(text, { ...options, filepath: absolute }))) findings.push({ code: FORMAT, level: 'error', path: file, message: `${file} is not formatted by prettier; run \`npm run format\`` });
41
+ } catch (error) {
42
+ findings.push({ code: FORMAT, level: 'error', path: file, message: `${file} cannot be formatted: ${String(error?.message ?? error).split('\n')[0]}` });
43
+ }
44
+ }
45
+ return findings;
46
+ }
package/sync/hygiene.mjs CHANGED
@@ -1,16 +1,21 @@
1
1
  // hfs work-hygiene: the guard for the two trees a back end tracks besides source. A file under .starciwork must be
2
2
  // product content (the .starciwork/.gitignore allowlist admits it, so agent output is refused), and a file under
3
- // .starcistacks must not be a plaintext secret (only *.enc is sealed). The pre-commit hook judges the staged files;
4
- // scripts/checks/check-hfs-sync.mjs judges every tracked file.
3
+ // .starcistacks must not be a plaintext secret (only *.enc is sealed). It is also the secrets guard of the commit: every staged file, in
4
+ // any tree, is read from the index and judged with the one secret judgement of `hfs check` (scripts/lib/hfs-rules/secrets.mjs: a secret by
5
+ // being, an .enc that is no sops envelope, a line that matches a secret pattern), so no plaintext secret reaches the history whatever
6
+ // .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. The pre-commit hook judges the staged
7
+ // files; scripts/checks/check-hfs-sync.mjs judges every tracked file.
5
8
  //
6
- // When this package runs from inside its own runtime checkout (packages/hfs lives 3 directories under the repo
7
- // root), it also reports the state-root ledger findings of scripts/lib/hk-orphan-ledgers.mjs: LEDGER_ORPHAN_STATE_ROOT
8
- // and LEDGER_LEGACY_WORK_SQLITE (COOK-BRIEF F4 handover, incident 2026-09-30). Installed standalone in a product
9
- // repository with no such checkout, that section is silently absent — never a crash, never a false negative claimed.
9
+ // Run inside a full runtime checkout — the repository under judgment is the checkout — it also reports the
10
+ // state-root ledger findings of that checkout's scripts/lib/hk-orphan-ledgers.mjs: LEDGER_ORPHAN_STATE_ROOT and
11
+ // LEDGER_LEGACY_WORK_SQLITE (COOK-BRIEF F4 handover, incident 2026-09-30). Any other repository — a product repo,
12
+ // a bare repo — carries no scripts/checks/ledger-hygiene.mjs at its root, so that section is silently absent:
13
+ // never a crash, never machine-state findings blamed on a repository that does not own them.
10
14
  import { execFileSync } from 'node:child_process';
11
15
  import fs from 'node:fs';
12
16
  import path from 'node:path';
13
- import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import { pathToFileURL } from 'node:url';
18
+ import { secretFileFindings } from '../runtime/scripts/lib/hfs-rules/secrets.mjs';
14
19
 
15
20
  const PLAINTEXT_NAME = /(^|\/)(\.env(\..*)?|[^/]*\.(pem|key|identity|age))$/;
16
21
  const GUARDED = file => file.startsWith('.starciwork/') || file.startsWith('.starcistacks/');
@@ -35,8 +40,8 @@ export function hygieneFindings(files, ignored) {
35
40
  findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
36
41
  }
37
42
  if (file.startsWith('.starcistacks/') && !file.endsWith('.enc') && !file.endsWith('.env.example')) {
38
- if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_STACKS_PLAINTEXT', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
39
- else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_STACKS_PLAINTEXT', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
43
+ if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
44
+ else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
40
45
  }
41
46
  }
42
47
  return findings;
@@ -50,41 +55,68 @@ export const stagedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only
50
55
  /** Every tracked file. */
51
56
  export const trackedFiles = cwd => gitList(cwd, ['ls-files', '-z']);
52
57
 
53
- /** Findings for `files` after keeping only the guarded trees. */
58
+ const MAX_STAGED_BYTES = 1024 * 1024;
59
+
60
+ /** The text of `file` as the index holds it (what the commit would record), or null when it is absent, binary or over 1 MB. */
61
+ export function stagedText(cwd, file) {
62
+ try {
63
+ const blob = execFileSync('git', ['show', `:${file}`], { cwd, maxBuffer: MAX_STAGED_BYTES * 2, stdio: ['ignore', 'pipe', 'ignore'] });
64
+ return blob.length > MAX_STAGED_BYTES || blob.includes(0) ? null : blob.toString('utf8');
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** The secret findings of `files` read from the index, as {file, code, message}; a file the name check already refused is not reported twice. */
71
+ export function secretGuardFindings(cwd, files, alreadyRefused = new Set()) {
72
+ return files.filter(file => !alreadyRefused.has(file)).flatMap(file => secretFileFindings({ file, text: stagedText(cwd, file) }).map(finding => ({ file, code: finding.code, message: finding.message })));
73
+ }
74
+
75
+ /** Findings for `files`: the guarded trees, then the secret guard over every file. */
54
76
  export function judge(cwd, files) {
55
77
  const guarded = files.filter(GUARDED);
56
- return { checked: guarded.length, findings: hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith('.starciwork/')))) };
78
+ const findings = hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith('.starciwork/'))));
79
+ const refused = new Set(findings.filter(finding => finding.code === 'HFS_PLAINTEXT_SECRET').map(finding => finding.file));
80
+ return { checked: files.length, findings: [...findings, ...secretGuardFindings(cwd, files, refused)] };
57
81
  }
58
82
 
59
- // The sibling scripts/checks/ledger-hygiene.mjs, 3 directories up from this file when it runs inside its own full
60
- // runtime checkout (packages/hfs/sync/ -> ../../.. is the repo root, the same computation
61
- // packages/hfs/scripts/sync-runtime.mjs uses). Installed standalone (no such checkout), it does not exist and this
62
- // section is silently absent — never a crash, never a false claim about a store this install cannot see.
63
- const RUNTIME_LEDGER_HYGIENE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'scripts', 'checks', 'ledger-hygiene.mjs');
83
+ // The scripts/checks/ledger-hygiene.mjs of the checkout under judgment: the root git names for `cwd`, which carries
84
+ // that script only when the repository IS a full StarCi runtime checkout (packages/hfs/sync/ sits 3 directories
85
+ // under such a root, the same computation packages/hfs/scripts/sync-runtime.mjs uses). A product repository — or a
86
+ // bare repo — has no such file, so the section is silently absent wherever this module happens to be installed.
87
+ const ledgerHygieneScript = cwd => {
88
+ try {
89
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }).trim();
90
+ return path.join(root, 'scripts', 'checks', 'ledger-hygiene.mjs');
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
64
95
 
65
96
  /**
66
- * The state-root ledger findings (LEDGER_ORPHAN_STATE_ROOT, LEDGER_LEGACY_WORK_SQLITE) as {code, file, message}
67
- * entries, when this install can reach the sibling runtime script; [] otherwise. Never throws: a report failure is
68
- * one HFS_LEDGER_HYGIENE_UNAVAILABLE finding, not a crash of `hfs work-hygiene`.
97
+ * The state-root ledger findings (LEDGER_ORPHAN_STATE_ROOT, LEDGER_LEGACY_WORK_SQLITE) of the checkout containing
98
+ * `cwd`, as {code, file, message} entries; [] when that checkout carries no ledger-hygiene script. Never throws: a
99
+ * report failure is one HFS_LEDGER_HYGIENE_UNAVAILABLE finding, not a crash of `hfs work-hygiene`.
69
100
  */
70
- export async function ledgerHygieneFindings() {
71
- if (!fs.existsSync(RUNTIME_LEDGER_HYGIENE)) return [];
101
+ export async function ledgerHygieneFindings(cwd = process.cwd()) {
102
+ const script = ledgerHygieneScript(cwd);
103
+ if (!script || !fs.existsSync(script)) return [];
72
104
  try {
73
- const { ledgerHygieneReport } = await import(pathToFileURL(RUNTIME_LEDGER_HYGIENE).href);
105
+ const { ledgerHygieneReport } = await import(pathToFileURL(script).href);
74
106
  const report = await ledgerHygieneReport({ apply: false });
75
107
  return [
76
108
  ...report.orphans.map(o => ({ code: o.code, file: o.ledgerId, message: `ledger ${o.name ?? o.ledgerId} - ${o.reason}; source roots: ${o.sourceRoots.join(', ') || '(none)'}` })),
77
109
  ...report.legacy.map(l => ({ code: l.code, file: l.repoRoot, message: `${l.files.length} legacy file(s) still in the repo: ${l.files.join(', ')}` })),
78
110
  ];
79
111
  } catch (error) {
80
- return [{ code: 'HFS_LEDGER_HYGIENE_UNAVAILABLE', file: RUNTIME_LEDGER_HYGIENE, message: `could not run: ${String(error?.message ?? error).slice(0, 200)}` }];
112
+ return [{ code: 'HFS_LEDGER_HYGIENE_UNAVAILABLE', file: script, message: `could not run: ${String(error?.message ?? error).slice(0, 200)}` }];
81
113
  }
82
114
  }
83
115
 
84
116
  /** `hfs work-hygiene`: checks the staged files, plus the state-root ledger findings when reachable; returns the exit code. */
85
117
  export async function runWorkHygiene({ cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), files } = {}) {
86
118
  const { checked, findings } = judge(cwd, files ?? stagedFiles(cwd));
87
- const ledgerFindings = await ledgerHygieneFindings();
119
+ const ledgerFindings = await ledgerHygieneFindings(cwd);
88
120
  const all = [...findings, ...ledgerFindings];
89
121
  for (const finding of all) out(`${finding.code} ${finding.file} ${finding.message}`);
90
122
  out(`hfs work-hygiene: ${checked} staged file(s) checked, ${findings.length} finding(s), ${ledgerFindings.length} ledger finding(s)`);