@starci/hfs 1.0.0 → 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 +57 -0
  2. package/README.md +116 -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 +10 -2
  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 +409 -140
  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 +95 -41
  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,47 @@
1
+ // fe-no-tests.mjs - FE_NO_TESTS (R97): a front-end repository has no tests by standard (owner 2026-09-30). There is no exception: a spec
2
+ // anywhere in a front-end repository is a finding, `scripts/` included.
3
+ // - a tracked test file: `*.spec.*`, `*.test.*`, `*-spec.*` (also `*.e2e-spec.*`);
4
+ // - a tracked test directory: `e2e/`, `__tests__/`, `__mocks__/` or `test-support/`;
5
+ // - a tracked test-tool file: `vitest.*`, `jest.*`, `playwright.*` (config, setup, workspace), `cypress.config.*`, `tsconfig.e2e.json`, `e2e.yml`;
6
+ // - a test script in a package.json: a script named `test`, `test:*` (or with a pre/post prefix), or one that runs a test runner;
7
+ // - a test dependency in a package.json: a test runner, a test environment, testing-library or an axe package.
8
+ // The check reads the tracked tree and the manifests only. One finding per path (a manifest: one per script or dependency), and it is
9
+ // the only finding of a test file: the slot check leaves these paths to it (isFeTestPath).
10
+ import { found, readJson } from './read.mjs';
11
+
12
+ export const FE_NO_TESTS = 'FE_NO_TESTS';
13
+ const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
14
+ const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
15
+ const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
+ const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
17
+ const TEST_RUNNER_COMMAND = /(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b/;
18
+ const TEST_DEPENDENCY = /^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$/;
19
+ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
20
+
21
+ /** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
22
+ export const isFeTestPath = (file) => SPEC_FILE.test(file) || TEST_DIRECTORY.test(file) || TEST_TOOL_FILE.test(file);
23
+
24
+ /** The findings of R97 over the tracked paths `files` of a front-end repository at `repoRoot`. */
25
+ export function feNoTestsFindings({ repoRoot, files }) {
26
+ const findings = [];
27
+ for (const file of files) {
28
+ if (isFeTestPath(file)) {
29
+ const what = SPEC_FILE.test(file) ? 'a test file' : TEST_DIRECTORY.test(file) ? 'inside a test directory' : 'test tooling';
30
+ findings.push(found(FE_NO_TESTS, file, `${file} is ${what}; a front-end repository has no tests, no test tooling and no exception: delete it`));
31
+ continue;
32
+ }
33
+ if (file !== 'package.json' && !file.endsWith('/package.json')) continue;
34
+ if (file.includes('node_modules/')) continue;
35
+ const pkg = readJson(repoRoot, file);
36
+ if (!pkg || typeof pkg !== 'object') continue;
37
+ for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
38
+ if (TEST_SCRIPT_NAME.test(name) || TEST_RUNNER_COMMAND.test(String(command))) findings.push(found(FE_NO_TESTS, file, `${file} script ${name} is a test script (${String(command).slice(0, 80)}); a front-end repository has no tests: delete the script`));
39
+ }
40
+ for (const section of DEPENDENCY_SECTIONS) {
41
+ for (const name of Object.keys(pkg[section] ?? {})) {
42
+ if (TEST_DEPENDENCY.test(name)) findings.push(found(FE_NO_TESTS, file, `${file} ${section} names ${name}, a test dependency; a front-end repository has no tests: delete the dependency`));
43
+ }
44
+ }
45
+ }
46
+ return findings;
47
+ }
@@ -0,0 +1,124 @@
1
+ // frontend.mjs - the front-end tree checks of hfs check, per app (`apps/<app>`, kind next):
2
+ // FE_WIRE_GENERATED (R52) wire types come from the contract copy: an app that keeps `modules/api/contract/*` declares the
3
+ // `codegen` script and runs it before `build` and `typecheck` (prebuild, pretypecheck), and its
4
+ // generated types on disk (`modules/api/__generated__/`, never tracked) are not older than the copy
5
+ // FE_I18N_PLACEMENT (R59) next-intl with the `[locale]` segment: `next-intl` is a dependency (of the app, the root or the shared
6
+ // i18n package under `packages/`), every route file sits under
7
+ // `src/app/[locale]/` (the root redirect page, global-error and health probes excepted), locale
8
+ // routing is `src/proxy.ts` and never `middleware.ts`, and the default locale's catalog `vi.json` exists
9
+ // FE_I18N_CATALOG (R60) every `modules/i18n/messages/<locale>.json` has the same key set
10
+ // The files each app must hold (routing.ts, navigation.ts, request.ts, messages/, the [locale] layout) are the slot manifest's requires
11
+ // (HFS_SLOT_REQUIRED_MISSING); which source may hold display text and how copy resolves is eslint-fe's.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { found, readJson } from './read.mjs';
15
+
16
+ export const WIRE_GENERATED = 'FE_WIRE_GENERATED';
17
+ export const I18N_PLACEMENT = 'FE_I18N_PLACEMENT';
18
+ export const I18N_CATALOG = 'FE_I18N_CATALOG';
19
+ export const DEFAULT_LOCALE = 'vi';
20
+ const ROUTE_FILE = /\/(?:page|layout|template|loading|error|not-found)\.[jt]sx?$/;
21
+ const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/;
22
+ const STALE_MS = 1000;
23
+
24
+ /** Every leaf key of a parsed catalog as a dotted path. */
25
+ export function catalogKeys(value, prefix = '') {
26
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return [prefix];
27
+ const entries = Object.entries(value);
28
+ return entries.length ? entries.flatMap(([key, child]) => catalogKeys(child, prefix ? `${prefix}.${key}` : key)) : [prefix];
29
+ }
30
+
31
+ /** The newest modification time (ms) of any file below `dir`, or null when it holds none. */
32
+ function newestBelow(dir) {
33
+ let newest = null;
34
+ const walk = (current) => {
35
+ let entries;
36
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
37
+ for (const entry of entries) {
38
+ const target = path.join(current, entry.name);
39
+ if (entry.isDirectory()) walk(target);
40
+ else try { newest = Math.max(newest ?? 0, fs.statSync(target).mtimeMs); } catch { /* vanished while reading */ }
41
+ }
42
+ };
43
+ walk(dir);
44
+ return newest;
45
+ }
46
+
47
+ const scriptsOf = (repoRoot, file) => readJson(repoRoot, file)?.scripts ?? {};
48
+
49
+ function wireFindings({ repoRoot, app, tracked }) {
50
+ const base = `apps/${app}`;
51
+ const copies = tracked.filter((file) => file.startsWith(`${base}/src/modules/api/contract/`));
52
+ if (!copies.length) return [];
53
+ const findings = [];
54
+ const manifest = `${base}/package.json`;
55
+ const scripts = scriptsOf(repoRoot, manifest);
56
+ const rootScripts = scriptsOf(repoRoot, 'package.json');
57
+ // A root script that only fans out to the workspaces (`npm run x --workspaces`, the managed FE root) is not this app's
58
+ // script: the app's own manifest must declare it.
59
+ const delegates = (script) => /(?:^|\s)--workspaces?(?:\s|=|$)/.test(String(script ?? ''));
60
+ const wired = (name) => scripts[name] ?? (delegates(rootScripts[name]) ? undefined : rootScripts[name]);
61
+ if (typeof wired('codegen') !== 'string') findings.push(found(WIRE_GENERATED, manifest, `${app} keeps a contract copy but declares no \`codegen\` script; wire types are generated from the copy, never typed by hand`, { app }));
62
+ else for (const hook of ['prebuild', 'pretypecheck']) {
63
+ if (!/\bcodegen\b/.test(String(wired(hook) ?? ''))) findings.push(found(WIRE_GENERATED, manifest, `${app} does not run codegen in \`${hook}\`; generate the wire types before build and typecheck`, { app, script: hook }));
64
+ }
65
+ const generated = `${base}/src/modules/api/__generated__`;
66
+ const built = newestBelow(path.join(repoRoot, generated));
67
+ if (built !== null) {
68
+ for (const copy of copies) {
69
+ let changed;
70
+ try { changed = fs.statSync(path.join(repoRoot, copy)).mtimeMs; } catch { continue; }
71
+ if (built + STALE_MS < changed) findings.push(found(WIRE_GENERATED, generated, `${generated} is older than ${copy}; run \`npm run codegen\` so the wire types match the contract copy`, { app, copy }));
72
+ }
73
+ }
74
+ return findings;
75
+ }
76
+
77
+ function placementFindings({ repoRoot, app, tracked }) {
78
+ const base = `apps/${app}`;
79
+ const findings = [];
80
+ const declares = (file) => { const pkg = readJson(repoRoot, file); return pkg ? ['dependencies', 'devDependencies'].some((section) => pkg[section]?.['next-intl'] !== undefined) : false; };
81
+ const manifest = `${base}/package.json`;
82
+ // The next-intl stack is written once per repository: in the app, or in the shared `packages/<family>-i18n` the apps call.
83
+ const shared = tracked.filter((file) => /^packages\/[^/]+\/package\.json$/.test(file));
84
+ if (tracked.includes(manifest) && ![manifest, 'package.json', ...shared].some(declares)) findings.push(found(I18N_PLACEMENT, manifest, `${app} does not depend on next-intl (nor does the root or a shared package); every app uses next-intl with the [locale] segment`, { app }));
85
+ if (!tracked.includes(`${base}/src/proxy.ts`)) findings.push(found(I18N_PLACEMENT, `${base}/src/proxy.ts`, `${app} has no src/proxy.ts; locale routing lives in proxy.ts`, { app }));
86
+ for (const file of tracked.filter((f) => new RegExp(`^${base}/src/middleware\.[cm]?[jt]s$`).test(f))) findings.push(found(I18N_PLACEMENT, file, `${file} is a middleware file; Next 16 routes through src/proxy.ts`, { app }));
87
+ const appDir = `${base}/src/app/`;
88
+ for (const file of tracked.filter((f) => f.startsWith(appDir) && ROUTE_FILE.test(f))) {
89
+ const rel = file.slice(appDir.length);
90
+ if (rel.startsWith('[locale]/') || rel.startsWith('health/') || ['page.tsx', 'not-found.tsx', 'layout.tsx'].includes(rel)) continue; // next-intl's root not-found and its layout
91
+ findings.push(found(I18N_PLACEMENT, file, `${file} is a route file outside the [locale] segment; every page, layout and boundary sits under src/app/[locale]/`, { app }));
92
+ }
93
+ const messages = `${base}/src/modules/i18n/messages/`;
94
+ const catalogs = tracked.filter((f) => f.startsWith(messages) && f.endsWith('.json'));
95
+ if (!catalogs.includes(`${messages}${DEFAULT_LOCALE}.json`)) findings.push(found(I18N_PLACEMENT, `${messages}${DEFAULT_LOCALE}.json`, `${app} has no ${DEFAULT_LOCALE}.json catalog; the default locale is ${DEFAULT_LOCALE}`, { app }));
96
+ for (const file of catalogs) {
97
+ const name = path.posix.basename(file, '.json');
98
+ if (path.posix.dirname(file) + '/' !== messages || !LOCALE.test(name)) findings.push(found(I18N_PLACEMENT, file, `${file} is not a <locale>.json catalog directly under modules/i18n/messages/`, { app }));
99
+ }
100
+ return findings;
101
+ }
102
+
103
+ function catalogFindings({ repoRoot, app, tracked }) {
104
+ const messages = `apps/${app}/src/modules/i18n/messages/`;
105
+ const catalogs = tracked.filter((f) => f.startsWith(messages) && f.endsWith('.json') && f.slice(messages.length).indexOf('/') === -1);
106
+ const keys = new Map();
107
+ for (const file of catalogs) {
108
+ const parsed = readJson(repoRoot, file);
109
+ if (parsed !== null) keys.set(file, new Set(catalogKeys(parsed)));
110
+ }
111
+ if (keys.size < 2) return [];
112
+ const all = new Set([...keys.values()].flatMap((set) => [...set]));
113
+ const findings = [];
114
+ for (const [file, set] of keys) {
115
+ const missing = [...all].filter((key) => !set.has(key)).sort();
116
+ if (missing.length) findings.push(found(I18N_CATALOG, file, `${file} lacks ${missing.length} key${missing.length === 1 ? '' : 's'} another locale has (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', ...' : ''}); every catalog holds the same keys`, { app, missing }));
117
+ }
118
+ return findings;
119
+ }
120
+
121
+ /** The front-end tree findings of R52, R59 and R60 for every `next` app of the repository. */
122
+ export function frontendFindings({ repoRoot, files, repo }) {
123
+ return repo.apps.filter((a) => a.kind === 'next').flatMap((a) => [wireFindings, placementFindings, catalogFindings].flatMap((check) => check({ repoRoot, app: a.name, tracked: files })));
124
+ }
@@ -0,0 +1,34 @@
1
+ // lint-suppression.mjs - HFS_LINT_SUPPRESSION_FILE (R104): a repository keeps no lint-suppression file, script or option. A finding is
2
+ // fixed in the code (or the rule is changed in the canon); it is never recorded away in a file that lets it stay.
3
+ // - a file named `eslint.suppressions*` or `eslint-suppressions*`;
4
+ // - a package.json script named `lint:suppressions` (or any `<x>:suppressions`), or one that passes `--suppressions-location`,
5
+ // `--suppress-all`, `--suppress-rule` or `--prune-suppressions` to eslint;
6
+ // - an `eslint.config.*` that names a suppressions configuration.
7
+ // Comment-level suppression (`eslint-disable`) is the lint rule `no-inline-suppression`'s (R18).
8
+ import { found, readJson, readText } from './read.mjs';
9
+
10
+ export const LINT_SUPPRESSION_FILE = 'HFS_LINT_SUPPRESSION_FILE';
11
+ const SUPPRESSION_FILE = /(?:^|\/)eslint[.-]suppressions[^/]*$/;
12
+ const SUPPRESSION_SCRIPT_NAME = /(?:^|:)suppressions$/;
13
+ const SUPPRESSION_FLAG = /--(?:suppressions-location|suppress-all|suppress-rule|prune-suppressions)\b/;
14
+ const ESLINT_CONFIG = /(?:^|\/)eslint\.config\.[cm]?[jt]s$/;
15
+ const CONFIG_SUPPRESSION = /suppressions/i;
16
+
17
+ /** The findings of R104 over the tracked paths `files` of the repository at `repoRoot`. */
18
+ export function lintSuppressionFindings({ repoRoot, files }) {
19
+ const findings = [];
20
+ for (const file of files) {
21
+ if (file.includes('node_modules/')) continue;
22
+ if (SUPPRESSION_FILE.test(file)) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} is a lint-suppression file; a finding is fixed in the code, never recorded in a file that lets it stay`));
23
+ else if (ESLINT_CONFIG.test(file)) {
24
+ const text = readText(repoRoot, file);
25
+ if (text !== null && CONFIG_SUPPRESSION.test(text)) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} passes a suppressions configuration to eslint; a repository suppresses nothing`));
26
+ } else if (file === 'package.json' || file.endsWith('/package.json')) {
27
+ const pkg = readJson(repoRoot, file);
28
+ for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
29
+ if (SUPPRESSION_SCRIPT_NAME.test(name) || SUPPRESSION_FLAG.test(String(command))) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} script ${name} manages lint suppressions (${String(command).slice(0, 80)}); delete the script and fix the findings in the code`));
30
+ }
31
+ }
32
+ }
33
+ return findings;
34
+ }
@@ -0,0 +1,51 @@
1
+ // pipeline.mjs - HFS_CI_MISSING_CANON (R13): CI runs the pinned `hfs check`; pre-push runs typecheck and lint.
2
+ // .github/workflows/ci.yml a `run:` step that runs `hfs check` as a whole (not `--fast`): `npx hfs check`, `npx @starci/hfs[@<version>]
3
+ // check`, or `npm run <script>` of the root package.json whose command is `hfs check`; a version named in
4
+ // the step is the @starci/hfs pin of canon-pins.yaml (an installed one is pinned by the pin check, R15)
5
+ // .husky/pre-push `npm run typecheck` and `npm run lint:check`, each on a line of its own
6
+ // A missing file is the slot manifest's finding (HFS_SLOT_REQUIRED_MISSING); a file that is present and lacks the step is this rule's.
7
+ // The redirect of `core.hooksPath` away from husky is the architecture machine's (HFS_HOOKS_PATH_REDIRECTED).
8
+ import { found, readJson, readText } from './read.mjs';
9
+
10
+ export const CI_MISSING_CANON = 'HFS_CI_MISSING_CANON';
11
+ export const CI_FILE = '.github/workflows/ci.yml';
12
+ export const PRE_PUSH_FILE = '.husky/pre-push';
13
+ export const hfsPackage = '@starci/hfs';
14
+ const RUN_LINE = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/;
15
+ const HFS_CHECK = /^(?:npx\s+(?:--no-install\s+|-y\s+)?)?(?:@starci\/hfs|hfs)(?:@(\S+))?\s+check(\s.*)?$/;
16
+ const NPM_RUN = /^npm run ([\w:.-]+)(?:\s+--\s+(.*))?$/;
17
+ const FAST = /(^|\s)--fast(\s|$)/;
18
+
19
+ const commandsOf = (text) => text.split(/\r?\n/).map((line) => RUN_LINE.exec(line)?.[1]).filter(Boolean).map((command) => command.replace(/^["']|["']$/g, ''));
20
+
21
+ /** `npm run <script> -- <args>` spelled out as the root package.json script it runs; any other command is itself. */
22
+ function expanded(command, scripts) {
23
+ const run = NPM_RUN.exec(command);
24
+ return run && typeof scripts[run[1]] === 'string' ? `${scripts[run[1]]}${run[2] ? ` ${run[2]}` : ''}` : command;
25
+ }
26
+
27
+ const runsStep = (lines, step) => lines.some((line) => new RegExp(`^npm run ${step}(\\s|$)`).test(line));
28
+
29
+ /** The findings of R13 over the repository at `repoRoot`; `pins` is the parsed canon-pins.yaml pin map. */
30
+ export function pipelineFindings({ repoRoot, files, pins }) {
31
+ const findings = [];
32
+ const pinned = pins?.[hfsPackage]?.version;
33
+ const ci = files.includes(CI_FILE) ? readText(repoRoot, CI_FILE) : null;
34
+ if (ci !== null) {
35
+ const scripts = readJson(repoRoot, 'package.json')?.scripts ?? {};
36
+ const checks = commandsOf(ci).map((command) => HFS_CHECK.exec(expanded(command, scripts))).filter(Boolean);
37
+ const whole = checks.filter((match) => !FAST.test(match[2] ?? ''));
38
+ if (!whole.length) findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`hfs check\` (\`npx hfs check\`, or an npm script that is exactly that); CI must run the whole pinned hfs check, not --fast`, { step: 'hfs check' }));
39
+ else if (pinned && !whole.some((match) => match[1] === undefined || match[1] === pinned)) {
40
+ findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs hfs check at ${whole[0][1]}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'hfs check', pinned }));
41
+ }
42
+ }
43
+ const prePush = files.includes(PRE_PUSH_FILE) ? readText(repoRoot, PRE_PUSH_FILE) : null;
44
+ if (prePush !== null) {
45
+ const lines = prePush.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
46
+ for (const step of ['typecheck', 'lint:check']) {
47
+ if (!runsStep(lines, step)) findings.push(found(CI_MISSING_CANON, PRE_PUSH_FILE, `${PRE_PUSH_FILE} does not run \`npm run ${step}\`; pre-push runs typecheck and lint`, { step }));
48
+ }
49
+ }
50
+ return findings;
51
+ }
@@ -0,0 +1,64 @@
1
+ // proof-commands.mjs - HFS_PROOF_COMMAND_FILE_MISSING (R105): a proof command a `.starciwork` record names runs files that exist.
2
+ // A record's `requiresProof.<kind>.command` is "the exact command that satisfies this kind, runnable as written" (work-implementation
3
+ // schema); a command that names a spec, a script or a config the repository does not hold cannot be run as written, and a record that
4
+ // keeps one goes quietly false (nivo-backend, 2026-09-30: `node --test scripts/provision-keycloak.spec.mjs` after the script was deleted).
5
+ // Every argument of the command that is a repository-relative file path (an extension, no glob, no variable, not absolute, not a `..`
6
+ // path into another repository) must be a tracked file or an existing one; a path an ignored slot owns (dist/, coverage/) is a build
7
+ // product and is not judged. One finding per record, kind and missing path. A back end's `.starciwork` also holds the records of the
8
+ // implementations that live in another repository (`repository: nivo-fe`); their commands run there, so only a record of this repository
9
+ // (its `repository` names it, or names none) is judged.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parseYaml } from '../../../engine/yaml.mjs';
13
+ import { repositoryName } from '../repo-identity.mjs';
14
+ import { found, readText } from './read.mjs';
15
+
16
+ export const PROOF_COMMAND_FILE_MISSING = 'HFS_PROOF_COMMAND_FILE_MISSING';
17
+ const RECORD = /^\.starciwork\/.+\.ya?ml$/;
18
+ const FILE_ARGUMENT = /^(?:\.\/)?[\w@][\w@.\-[\]()]*(?:\/[\w@.\-[\]()]+)*\.[A-Za-z][A-Za-z0-9]{0,7}$/;
19
+ const PATH_SEPARATOR = /\//;
20
+
21
+ /** The arguments of a shell command line that name a repository file, in order, without duplicates. */
22
+ export function commandFiles(command) {
23
+ const words = String(command).split(/[\s&|;()<>]+/).filter(Boolean).map((word) => word.replace(/^['"]|['"]$/g, ''));
24
+ const files = [];
25
+ for (const word of words) {
26
+ if (word.startsWith('-') || word.startsWith('..') || path.isAbsolute(word) || /^[A-Za-z]:/.test(word) || /^[a-z]+:\/\//.test(word)) continue;
27
+ if (!PATH_SEPARATOR.test(word) || /[*$`{}~=]/.test(word)) continue;
28
+ if (!FILE_ARGUMENT.test(word)) continue;
29
+ const clean = word.replace(/^\.\//, '');
30
+ if (!files.includes(clean)) files.push(clean);
31
+ }
32
+ return files;
33
+ }
34
+
35
+ /** Every `requiresProof.<kind>.command` of a parsed record: [{ kind, command }]. */
36
+ function proofCommands(record) {
37
+ const demands = record && typeof record === 'object' ? record.requiresProof : null;
38
+ if (!demands || typeof demands !== 'object') return [];
39
+ return Object.entries(demands).flatMap(([kind, demand]) => (typeof demand?.command === 'string' ? [{ kind, command: demand.command }] : []));
40
+ }
41
+
42
+ /** The findings of R105 over the tracked paths `files` of the repository at `repoRoot`; `resolver` names the ignored slots. */
43
+ export function proofCommandFindings({ repoRoot, files, resolver }) {
44
+ const findings = [];
45
+ const tracked = new Set(files);
46
+ const own = repositoryName(repoRoot);
47
+ for (const file of files) {
48
+ if (!RECORD.test(file)) continue;
49
+ const text = readText(repoRoot, file);
50
+ if (text === null || !text.includes('requiresProof')) continue;
51
+ let record;
52
+ try { record = parseYaml(text); } catch { continue; }
53
+ if (typeof record?.repository === 'string' && record.repository !== own) continue;
54
+ for (const { kind, command } of proofCommands(record)) {
55
+ for (const target of commandFiles(command)) {
56
+ if (tracked.has(target) || fs.existsSync(path.join(repoRoot, target))) continue;
57
+ const classified = resolver.classifyPath(target);
58
+ if (classified.status === 'owned' && classified.tracking === 'ignored') continue;
59
+ findings.push(found(PROOF_COMMAND_FILE_MISSING, file, `${file} requiresProof.${kind}.command runs ${target}, which the repository does not hold; a proof command is runnable as written: create the file or rewrite the proof plan`, { kind, missing: target }));
60
+ }
61
+ }
62
+ }
63
+ return findings;
64
+ }
@@ -0,0 +1,28 @@
1
+ // read.mjs - what the tree checks of hfs-check.mjs share: one bounded text read and the shape of a finding.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /** Files above this size are not read by a tree check (a tracked binary or a generated dump is no source of these laws). */
6
+ export const MAX_READ_BYTES = 1024 * 1024;
7
+
8
+ /** The text of `rel` under `repoRoot`, or null when it is absent, unreadable, over MAX_READ_BYTES or binary. */
9
+ export function readText(repoRoot, rel) {
10
+ try {
11
+ const target = path.join(repoRoot, rel);
12
+ if (fs.statSync(target).size > MAX_READ_BYTES) return null;
13
+ const text = fs.readFileSync(target, 'utf8');
14
+ return text.includes('\0') ? null : text;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /** The JSON of `rel`, or null when it is absent or not JSON. */
21
+ export function readJson(repoRoot, rel) {
22
+ const text = readText(repoRoot, rel);
23
+ if (text === null) return null;
24
+ try { return JSON.parse(text); } catch { return null; }
25
+ }
26
+
27
+ /** One error finding of `code` on `file`; `extra` carries the finding's own fields. */
28
+ export const found = (code, file, message, extra = {}) => ({ code, level: 'error', path: file, ...extra, message });
@@ -0,0 +1,32 @@
1
+ // repo-local-checks.mjs - HFS_REPO_LOCAL_CHECK (R103): a repository carries no check, lint rule or lint plugin of its own. Every check
2
+ // lives in the .claude runtime or in a canon package (`@starci/eslint-canon-*`), so a repository has none to keep in step.
3
+ // - a file or folder named `eslint-local-rules*`, `eslint-plugin*` or `eslint-local-plugin*`, or one below an `eslint-rules/` or `lint-rules/` folder;
4
+ // - a `check-*` file in a `scripts/` or `tools/` folder (a check under another name is the reviewer's, not a name test's);
5
+ // - a package.json script that runs a `check-*` file of `scripts/` or `tools/`.
6
+ // The check reads the tracked tree and the manifests. `scripts/` may keep operational scripts (repo.scripts); it keeps no check.
7
+ // Not judged here, because another rule already is: a file that defines an ESLint rule or a stylelint plugin by its content, and a script that
8
+ // swaps the configuration (HFS_TOOL_CONFIG_LOCAL, R16), and an eslint.config that differs from the render, a relative import of a local plugin
9
+ // included (HFS_RULE_OFF_WITHOUT_REPLACEMENT, R17). This rule adds the forbidden roles by name: what a check or a rule file is called.
10
+ import { found, readJson } from './read.mjs';
11
+
12
+ export const REPO_LOCAL_CHECK = 'HFS_REPO_LOCAL_CHECK';
13
+ const LOCAL_RULE_FILE = /(?:^|\/)(?:eslint-local-rules|eslint-plugin|eslint-local-plugin)[^/]*(?:\/|$)|(?:^|\/)(?:eslint-rules|lint-rules)\//;
14
+ const CHECK_SCRIPT = /(?:^|\/)(?:scripts|tools)\/check-[^/]+$/;
15
+ const RUNS_CHECK = /(?:^|[\s&|;(])(?:\.\/)?(?:scripts|tools)\/check-[^\s&|;)]+/;
16
+
17
+ /** The findings of R103 over the tracked paths `files` of the repository at `repoRoot`. */
18
+ export function repoLocalCheckFindings({ repoRoot, files }) {
19
+ const findings = [];
20
+ for (const file of files) {
21
+ if (file.includes('node_modules/')) continue;
22
+ if (LOCAL_RULE_FILE.test(file)) findings.push(found(REPO_LOCAL_CHECK, file, `${file} is a local lint rule or plugin; a repository keeps no rule of its own: the rule is proposed to the canon (@starci/eslint-canon-*) in the .claude runtime`));
23
+ else if (CHECK_SCRIPT.test(file)) findings.push(found(REPO_LOCAL_CHECK, file, `${file} is a check kept in the repository; every check lives in the .claude runtime (hfs check, the canons), so a repository has none to keep`));
24
+ else if (file === 'package.json' || file.endsWith('/package.json')) {
25
+ const pkg = readJson(repoRoot, file);
26
+ for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
27
+ if (RUNS_CHECK.test(String(command))) findings.push(found(REPO_LOCAL_CHECK, file, `${file} script ${name} runs a repository-local check (${String(command).slice(0, 80)}); delete the script, the check belongs to the .claude runtime`));
28
+ }
29
+ }
30
+ }
31
+ return findings;
32
+ }
@@ -0,0 +1,54 @@
1
+ // secrets.mjs - HFS_PLAINTEXT_SECRET (R06): secrets exist only as `.starcistacks/<env>/secrets/<slug>.enc` sops envelopes.
2
+ // It judges the tracked tree with the one list of secret shapes the push scan refuses (scripts/lib/secret-patterns.mjs):
3
+ // - a tracked file that is a secret by being (an env file, a key file, a credentials file, a plaintext member of a
4
+ // secrets directory), and every path a forbidden `external` slot names HFS_PLAINTEXT_SECRET for (the slot manifest);
5
+ // - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
6
+ // - a line of any other tracked text file that matches a secret pattern.
7
+ // A finding names the file, the line and the pattern, never the value.
8
+ import { FORBIDDEN_FILES, secretHits } from '../secret-patterns.mjs';
9
+ import { isSopsEnvelope } from '../test-secrets.mjs';
10
+ import { found, readText } from './read.mjs';
11
+
12
+ export const PLAINTEXT_SECRET = 'HFS_PLAINTEXT_SECRET';
13
+ /** Files whose lines are not secrets: a lockfile carries integrity hashes, never a credential. */
14
+ const NOT_SCANNED = /(^|\/)package-lock\.json$/;
15
+
16
+ /** True when the slot `id` of the manifest reports HFS_PLAINTEXT_SECRET for the paths it forbids. */
17
+ export const slotOwnsSecrets = (slot) => slot?.rules?.includes(PLAINTEXT_SECRET) === true;
18
+
19
+ /**
20
+ * The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
21
+ * no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
22
+ * large, absent). The whole-tree check reads the work tree and the commit hook (`hfs work-hygiene`) reads the staged blob: one judgement.
23
+ */
24
+ export function secretFileFindings({ file, text }) {
25
+ const being = FORBIDDEN_FILES.find((rule) => rule.test(file));
26
+ if (being) return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
27
+ if (text === null || text === undefined || NOT_SCANNED.test(file)) return [];
28
+ if (file.endsWith('.enc')) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
29
+ const findings = [];
30
+ const lines = text.split(/\r?\n/);
31
+ const seen = new Set();
32
+ for (let index = 0; index < lines.length; index += 1) {
33
+ for (const pattern of secretHits(file, lines[index])) {
34
+ if (seen.has(pattern)) continue;
35
+ seen.add(pattern);
36
+ findings.push(found(PLAINTEXT_SECRET, file, `${file}:${index + 1} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line: index + 1, pattern }));
37
+ }
38
+ }
39
+ return findings;
40
+ }
41
+
42
+ /** The findings of R06 over `files` (tracked paths) of `repoRoot`; `resolver` names the slots. */
43
+ export function secretFindings({ repoRoot, files, resolver }) {
44
+ const findings = [];
45
+ for (const file of files) {
46
+ const slot = resolver.classifyPath(file);
47
+ if (slot.status === 'forbidden' && slotOwnsSecrets(resolver.slot(slot.slot))) {
48
+ findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${slot.goesTo ? `; it belongs at ${slot.goesTo}` : ''}`, { slot: slot.slot, goesTo: slot.goesTo }));
49
+ continue;
50
+ }
51
+ findings.push(...secretFileFindings({ file, text: readText(repoRoot, file) }));
52
+ }
53
+ return findings;
54
+ }
@@ -0,0 +1,31 @@
1
+ // spec-placement.mjs - BE_SPEC_PLACEMENT (R102): a back-end spec or test file lives in one of the four test layers and nowhere else.
2
+ // - a unit spec is `<name>.service.spec.ts` beside its service, in a slot whose `tests` is `unit-beside` (the slot and the lint rule
3
+ // `unit-test-colocated` judge the name);
4
+ // - integration, e2e and contract specs are `src/tests/{integration,e2e,contract}/...`, the slots whose `tests` is `e2e`;
5
+ // - an app's own folder (`apps/<app>/src/`, the slots that name an `appKind`) holds the app's composition spec, which the architecture
6
+ // machine's app-composition check judges.
7
+ // Every other tracked `*.spec.*`, `*.test.*` or `*-spec.*` file is a finding, `scripts/` and `tools/` included: an operational script
8
+ // carries no spec, and a spec that guards one moves into a layer or goes. A file no slot owns (`tools/x.spec.ts`) is judged too, so the
9
+ // finding names the rule rather than only the missing slot. Which folder is a layer is read from the slot manifest, never spelled here.
10
+ import { found } from './read.mjs';
11
+
12
+ export const SPEC_PLACEMENT = 'BE_SPEC_PLACEMENT';
13
+ const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
14
+ const TEST_SLOT_TESTS = new Set(['unit-beside', 'e2e']);
15
+
16
+ /** True for a file name that is a spec or a test. */
17
+ export const isSpecFile = (file) => SPEC_FILE.test(file);
18
+
19
+ /** The findings of R102 over the tracked paths `files` of a back-end repository; `resolver` names the slots. */
20
+ export function specPlacementFindings({ files, resolver }) {
21
+ const findings = [];
22
+ for (const file of files) {
23
+ if (!isSpecFile(file) || file.includes('node_modules/')) continue;
24
+ const classified = resolver.classifyPath(file);
25
+ const slot = classified.status === 'owned' ? resolver.slot(classified.slot) : null;
26
+ if (slot && (TEST_SLOT_TESTS.has(slot.tests) || slot.appKind !== undefined)) continue;
27
+ const where = slot ? `slot ${slot.id}` : 'no slot';
28
+ findings.push(found(SPEC_PLACEMENT, file, `${file} is a spec outside the test layers (${where}); a unit spec is <name>.service.spec.ts beside its service and an integration, e2e or contract spec sits under src/tests/{integration,e2e,contract}; an operational script or a tool carries no spec`));
29
+ }
30
+ return findings;
31
+ }
@@ -0,0 +1,54 @@
1
+ // stacks.mjs - HFS_STACKS_SHAPE (R10): `.starcistacks` has the standard shape and the host Sonar owner.
2
+ // - every tracked path under `.starcistacks/` is one the be.starcistacks slot allows (its `allows` list is the shape:
3
+ // application-stacks.yaml and <env>/{README.md, environment.json, infra/{compose,k8s,terraform}, runtime/{config,env},
4
+ // secrets/<slug>.enc, seeds}); a `.enc` outside <env>/secrets/ is never allowed, `runtime/files/`, a root `DESIGN.md`,
5
+ // `deployment.json` and `k8s/` are not in the list, so they fall out of it;
6
+ // - the declaration (read by scripts/lib/stack-declaration.mjs, the one reader of it) states a sonar service, a local
7
+ // Sonar is owned by the host (`stack.owner: host`, root `.claude/ext/sonar`), and no service still points at `.stacks`.
8
+ // The declaration's services contract (custody, CI wiring, project keys) stays check-starcistacks's; this file judges shape only.
9
+ import { braceVariants, globExpression } from '../glob.mjs';
10
+ import { declaredStack, findStackDeclaration, STACK_ROOT, text } from '../stack-declaration.mjs';
11
+ import { found } from './read.mjs';
12
+
13
+ export const STACKS_SHAPE = 'HFS_STACKS_SHAPE';
14
+ export const STACKS_SLOT = 'be.starcistacks';
15
+ export const HOST_SONAR_ROOT = '.claude/ext/sonar';
16
+ const SEALED = /\.enc$/;
17
+ const INSIDE_SECRETS = /^[^/]+\/secrets\/[^/]+\.enc$/;
18
+ const RETIRED_ROOT = /^\.?stacks(?:\/|$)/;
19
+
20
+ /** The anchored expressions of a slot's `allows` entries: `<name>` is one path segment, braces alternate. */
21
+ const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(entry.replace(/<[a-z][a-z0-9-]*>/g, '*'))).map((variant) => globExpression(variant.endsWith('/') ? `${variant}**` : variant));
22
+
23
+ const shapeFinding = (file, message, extra) => found(STACKS_SHAPE, file, message, extra);
24
+
25
+ /** The shape findings of the `.starcistacks` tree of a back-end repository. */
26
+ export function stacksFindings({ repoRoot, files, resolver }) {
27
+ const slot = resolver.slot(STACKS_SLOT);
28
+ if (!slot) return [];
29
+ const findings = [];
30
+ const allowed = allowedExpressions(slot.allows ?? []);
31
+ const prefix = `${STACK_ROOT}/`;
32
+ for (const file of files.filter((f) => f.startsWith(prefix))) {
33
+ const rel = file.slice(prefix.length);
34
+ if (SEALED.test(rel) && !INSIDE_SECRETS.test(rel)) findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc; move it to .starcistacks/<env>/secrets/`));
35
+ else if (!allowed.some((expression) => expression.test(rel))) findings.push(shapeFinding(file, `${file} is not part of the standard .starcistacks shape (application-stacks.yaml and <env>/{README.md, environment.json, infra, runtime/{config,env}, secrets/<slug>.enc, seeds}); move or delete it`));
36
+ }
37
+ if (!files.some((f) => f === `${prefix}application-stacks.yaml`)) return findings;
38
+ const declaration = findStackDeclaration(repoRoot);
39
+ const declared = `${prefix}application-stacks.yaml`;
40
+ if (declaration.error) return [...findings, shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
41
+ const services = declaration.doc?.services;
42
+ if (services === undefined || typeof services.sonar !== 'object' || services.sonar === null) {
43
+ findings.push(shapeFinding(declared, `${declared} declares no sonar service; every repository states its Sonar owner (a local one is owned by the host: stack.owner host, root ${HOST_SONAR_ROOT})`));
44
+ return findings;
45
+ }
46
+ for (const [id, entry] of Object.entries(services)) {
47
+ const stack = declaredStack(entry);
48
+ if (stack?.root && RETIRED_ROOT.test(stack.root)) findings.push(shapeFinding(declared, `${declared} services.${id}.stack.root ${stack.root} still points at the retired .stacks root; the stack root is ${STACK_ROOT}`));
49
+ if (id === 'sonar' && text(entry?.mode) === 'local' && !(stack?.hostOwned && stack.root === HOST_SONAR_ROOT)) {
50
+ findings.push(shapeFinding(declared, `${declared} services.sonar is a local Sonar but is not owned by the host; declare stack.owner host with root ${HOST_SONAR_ROOT}`));
51
+ }
52
+ }
53
+ return findings;
54
+ }
@@ -0,0 +1,31 @@
1
+ // test-topology.mjs - BE_TEST_TOPOLOGY (R47), the tree half of BE-CONVENTION 1.16: four test kinds by folder, one jest configuration.
2
+ // - a unit spec is `<name>.spec.ts`: a tracked `*.test.*` file is refused;
3
+ // - there is no `testing/` folder: doubles and builders live in `src/tests/fixtures/`, next to the subject as `*.spec.ts`;
4
+ // - one `jest.config.js` at the root: a second jest config (`jest.config.<x>.js`, `jest.<x>.config.*`, one per app) and a
5
+ // `jest` key in a package.json are refused.
6
+ // `int-spec`, `harness-spec`, the retired test folders and the per-lane configs under src/tests are the architecture machine's
7
+ // (HFS_TEST_KIND_RETIRED, a sub-code of this rule); that jest.config.js is the rendered one (projects `unit`, `integration`, `e2e` and
8
+ // `contract`, diagnostics off) is the managed-file check's (HFS_MANAGED_FILE_DRIFT); a spec whose suffix disagrees with its src/tests folder has no slot (HFS_SLOT_UNDECLARED).
9
+ import { found, readJson } from './read.mjs';
10
+
11
+ export const TEST_TOPOLOGY = 'BE_TEST_TOPOLOGY';
12
+ const DOT_TEST = /\.test\.[cm]?[jt]sx?$/;
13
+ const JEST_CONFIG = /(?:^|\/)jest(?:\.[^/]+)*\.config(?:\.[^/]+)*\.(?:[cm]?[jt]s|json)$|(?:^|\/)jest\.config\.[^/]+$/;
14
+ const ROOT_JEST_CONFIG = 'jest.config.js';
15
+
16
+ /** The findings of R47 over the tracked paths `files` of a back-end repository at `repoRoot`. */
17
+ export function testTopologyFindings({ repoRoot, files }) {
18
+ const findings = [];
19
+ for (const file of files) {
20
+ const segments = file.split('/');
21
+ if (DOT_TEST.test(file)) findings.push(found(TEST_TOPOLOGY, file, `${file} is a \`.test\` file; a unit spec is <name>.spec.ts beside its subject and an integration, e2e or contract spec is *.integration-spec.ts, *.e2e-spec.ts or *.contract-spec.ts under its own src/tests folder`));
22
+ else if (segments.slice(0, -1).includes('testing')) findings.push(found(TEST_TOPOLOGY, file, `${file} sits in a testing/ folder; doubles and builders live in src/tests/fixtures/ and specs sit beside their subject`));
23
+ else if (JEST_CONFIG.test(file) && file !== ROOT_JEST_CONFIG) findings.push(found(TEST_TOPOLOGY, file, `${file} is a second jest configuration; the one root ${ROOT_JEST_CONFIG} declares the projects unit, integration, e2e and contract`));
24
+ if (file === 'package.json' || file.endsWith('/package.json')) {
25
+ if (file.includes('node_modules/')) continue;
26
+ const pkg = readJson(repoRoot, file);
27
+ if (pkg && Object.hasOwn(pkg, 'jest')) findings.push(found(TEST_TOPOLOGY, file, `${file} carries a jest key; jest is configured only by the root ${ROOT_JEST_CONFIG}`));
28
+ }
29
+ }
30
+ return findings;
31
+ }