@starci/hfs 2.0.2 → 4.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 (207) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +33 -33
  3. package/bin/hfs.mjs +94 -68
  4. package/lint/run.mjs +161 -0
  5. package/package.json +3 -2
  6. package/report/sonar.mjs +14 -29
  7. package/runtime/engine/admission.mjs +3 -3
  8. package/runtime/engine/ledger-db.mjs +2 -2
  9. package/runtime/engine/machine-db.mjs +90 -9
  10. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  11. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  12. package/runtime/knowledge/hfs/canon-pins.yaml +32 -13
  13. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  14. package/runtime/knowledge/hfs/slots.yaml +193 -128
  15. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  16. package/runtime/knowledge/sonar-gate.yaml +8 -7
  17. package/runtime/modules/kernel/failure-codes.yaml +31 -52
  18. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  19. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  20. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  21. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  22. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  23. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  24. package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/hfs.mjs +105 -67
  26. package/runtime/scripts/checks/architecture/index.mjs +18 -13
  27. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  28. package/runtime/scripts/checks/architecture/owners.mjs +12 -6
  29. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  30. package/runtime/scripts/checks/architecture/surface.mjs +91 -0
  31. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  32. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  33. package/runtime/scripts/checks/architecture/typescript.mjs +127 -29
  34. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  35. package/runtime/scripts/lib/hfs-check.mjs +160 -209
  36. package/runtime/scripts/lib/hfs-path-findings.mjs +95 -0
  37. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  38. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  39. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  40. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
  41. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  42. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
  43. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  44. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  45. package/runtime/scripts/lib/language.mjs +11 -1
  46. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  47. package/scaffold/app.mjs +179 -0
  48. package/scaffold/service.mjs +26 -16
  49. package/sync/cli.mjs +1 -1
  50. package/sync/hygiene.mjs +11 -8
  51. package/sync/index.mjs +109 -111
  52. package/sync/managed.mjs +9 -8
  53. package/sync/sonar-key.mjs +20 -22
  54. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +5 -11
  55. package/templates/app/gitignore +6 -0
  56. package/templates/app/hooks/husky/pre-commit +25 -0
  57. package/templates/app/hooks/husky/pre-push +7 -0
  58. package/templates/app/package-scripts/package.json +22 -0
  59. package/templates/{be → app}/quality-config/sonar-project.properties +4 -3
  60. package/templates/app/skeleton/.editorconfig +15 -0
  61. package/templates/app/skeleton/.gitattributes +2 -0
  62. package/templates/app/skeleton/.nvmrc +1 -0
  63. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  64. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  65. package/templates/app/skeleton/README.md +36 -0
  66. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  67. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  68. package/templates/be/skeleton/.sops.yaml +2 -0
  69. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  70. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  71. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  72. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  73. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  76. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  77. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  78. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  79. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  80. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  81. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  82. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  83. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  84. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  85. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  86. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  87. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  88. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  89. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  90. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  91. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  95. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  96. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  97. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  98. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  99. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  100. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  101. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  102. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  103. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  107. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  108. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  109. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  110. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  111. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  112. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  113. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  114. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  118. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  119. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  120. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  121. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  122. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  123. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  124. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  125. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  126. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  127. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  128. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  129. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  130. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  131. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  132. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  133. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  134. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  135. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  136. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  137. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  138. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  139. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  140. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  141. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  142. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  143. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  147. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  148. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  149. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  150. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  151. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  152. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  153. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  154. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  155. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  156. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  157. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  158. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  159. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  160. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  161. package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
  162. package/sync/skeleton.mjs +0 -76
  163. package/templates/be/gitignore +0 -2
  164. package/templates/be/hooks/husky/pre-commit +0 -13
  165. package/templates/be/hooks/husky/pre-push +0 -7
  166. package/templates/be/package-scripts/package.json +0 -22
  167. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  168. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  169. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  170. package/templates/be/tool-config/prettierignore +0 -8
  171. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -54
  172. package/templates/fe/gitignore +0 -3
  173. package/templates/fe/hooks/husky/pre-commit +0 -16
  174. package/templates/fe/hooks/husky/pre-push +0 -6
  175. package/templates/fe/package-scripts/package.json +0 -17
  176. package/templates/fe/parts/api-client.ts +0 -44
  177. package/templates/fe/parts/api-outcome.ts +0 -7
  178. package/templates/fe/quality-config/sonar-project.properties +0 -8
  179. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  180. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  181. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  182. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  183. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  184. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  185. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  186. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  187. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  188. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  189. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  190. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  191. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  192. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  193. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  194. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  195. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  196. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  197. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  198. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  199. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  200. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  201. package/templates/fe/tool-config/prettierrc +0 -1
  202. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  203. /package/templates/{be → app}/starciwork.gitignore +0 -0
  204. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  205. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  206. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  207. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -1,9 +1,9 @@
1
- // hfs-check.mjs - the HFS repository check, behind `hfs check | init | explain` (packages/hfs) and reusable by any
2
- // runtime check. It reads three things and nothing else: the repository's hfs.json, the slot manifest
1
+ // hfs-check.mjs - the HFS app check, behind `hfs check | explain` (packages/hfs) and reusable by any
2
+ // runtime check. It reads three things and nothing else: the app's hfs.json, the slot manifest
3
3
  // (knowledge/hfs/slots.yaml through scripts/lib/hfs-slots.mjs) and the pins (knowledge/hfs/canon-pins.yaml). It never
4
4
  // writes to the repository it inspects.
5
5
  //
6
- // checkRepo() answers, for the tracked paths of one repository (git ls-files):
6
+ // checkRepo() answers, for the tracked paths of one app (git ls-files), per scope (the app root and each side folder):
7
7
  // HFS_SLOT_UNDECLARED a tracked path no slot owns (the nearest slot is named)
8
8
  // HFS_SLOT_NOT_ENABLED a tracked path in an opt-in slot the repository did not declare
9
9
  // HFS_SLOT_AMBIGUOUS two slots of equal specificity own the path (a manifest gap, reported not guessed)
@@ -26,6 +26,7 @@
26
26
  // HFS_REPO_LOCAL_CHECK (R103, hfs-rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
27
27
  // HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
28
28
  // HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
29
+ // HFS_PEER_INTEGRATION_MISSING (R111, hfs-rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
29
30
  // FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
30
31
  // HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
31
32
  // HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
@@ -45,22 +46,24 @@ import path from 'node:path';
45
46
  import { skillRoot } from '../../engine/runtime-root.mjs';
46
47
  import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture/index.mjs';
47
48
  import { parseYaml } from '../../engine/yaml.mjs';
48
- import { HFS_DECLARATION_FILE, HfsSlotsError, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
49
+ import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
49
50
  import { allowsFile } from './hfs-allows.mjs';
50
- import { isDir } from './fs-kind.mjs';
51
51
  import { gitOutput } from './git.mjs';
52
52
  import { posixPath } from './path-key.mjs';
53
53
  import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
54
54
  import { contractFindings } from './hfs-rules/contract.mjs';
55
55
  import { depFindings } from './hfs-rules/deps.mjs';
56
- import { frontendFindings } from './hfs-rules/frontend.mjs';
56
+ import { appFrontendFindings, frontendFindings } from './hfs-rules/frontend.mjs';
57
57
  import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
58
+ import { peerIntegrationFindings } from './hfs-rules/peer-integrations.mjs';
58
59
  import { pipelineFindings } from './hfs-rules/pipeline.mjs';
59
60
  import { proofCommandFindings } from './hfs-rules/proof-commands.mjs';
60
61
  import { repoLocalCheckFindings } from './hfs-rules/repo-local-checks.mjs';
61
62
  import { readJson } from './hfs-rules/read.mjs';
62
- import { secretFindings, slotOwnsSecrets } from './hfs-rules/secrets.mjs';
63
- import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
63
+ import { secretFindings } from './hfs-rules/secrets.mjs';
64
+ import { pathFindings } from './hfs-path-findings.mjs';
65
+ import { onLintSurface } from '../checks/architecture/surface.mjs';
66
+ import { checkAppRoot, trackedTreeView } from '../checks/architecture/hfs.mjs';
64
67
  import { stacksFindings } from './hfs-rules/stacks.mjs';
65
68
  import { testTopologyFindings } from './hfs-rules/test-topology.mjs';
66
69
  import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
@@ -68,11 +71,11 @@ import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
68
71
  export const CANON_PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
69
72
  export const FAILURE_CODES_FILE = 'modules/kernel/failure-codes.yaml';
70
73
  /**
71
- * The codes `hfs check` and `hfs init` report when they cannot judge (an unreadable repository, a refused declaration or
72
- * manifest, an init that cannot proceed): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
74
+ * The codes `hfs check` reports when it cannot judge (an unreadable repository, a refused declaration or manifest, a missing
75
+ * formatter): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
73
76
  */
74
77
  export const REFUSAL_CODES = Object.freeze([
75
- 'HFS_INIT_EXISTS', 'HFS_INIT_UNDETECTED', 'HFS_REPO_UNREADABLE',
78
+ 'HFS_REPO_UNREADABLE',
76
79
  'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID', 'HFS_FORMAT_TOOL_MISSING',
77
80
  ]);
78
81
  /** The codes this module emits that are not the slot loader's own: the why bundle of packages/hfs ships exactly these plus the loader's. */
@@ -81,7 +84,7 @@ export const CHECK_CODES = Object.freeze([
81
84
  'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
82
85
  'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
83
86
  'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
84
- 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
87
+ 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
85
88
  'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
86
89
  'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
87
90
  ...REFUSAL_CODES,
@@ -136,7 +139,8 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
136
139
  let pkg;
137
140
  try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
138
141
  for (const [name, pin] of Object.entries(pins)) {
139
- if (pin.side !== 'both' && pin.side !== profile) continue;
142
+ // The app root's one package.json carries the pins of both sides.
143
+ if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
140
144
  for (const section of DEP_SECTIONS) {
141
145
  const spec = pkg[section]?.[name];
142
146
  if (spec === undefined) continue;
@@ -148,53 +152,6 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
148
152
  return findings;
149
153
  }
150
154
 
151
- const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
152
- const SOURCE_ROOT = /^(?:src|apps)\//;
153
- const FREE_NAMES = new Set(['index.ts', 'main.ts']);
154
- const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
155
-
156
- /**
157
- * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
158
- * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
159
- * as api.composition.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
160
- * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
161
- */
162
- function sourceFormFindings({ files, resolver }) {
163
- const { suffixes, bannedSuffixes } = resolver.ruleParams();
164
- // A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
165
- // anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
166
- const boundSuffixes = new Map();
167
- for (const slot of resolver.slots()) {
168
- const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
169
- if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
170
- }
171
- const findings = [];
172
- for (const file of files) {
173
- if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
174
- const c = resolver.classifyPath(file);
175
- if (c.status !== 'owned' || c.tracking === 'ignored') continue;
176
- const base = path.posix.basename(file);
177
- if (FREE_NAMES.has(base)) continue;
178
- // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
179
- const slot = resolver.slot(c.slot);
180
- if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
181
- // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
182
- const admitted = allowsFile(resolver, file);
183
- if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
184
- if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
185
- const parts = base.slice(0, -'.ts'.length).split('.');
186
- const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
187
- if (banned) {
188
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})` });
189
- } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
190
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
191
- } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
192
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
193
- }
194
- }
195
- return findings;
196
- }
197
-
198
155
  /** Instances (slot, root, bindings) present in the tracked tree, for every slot that names required files or a minimum. */
199
156
  function instancesOf(resolver, files) {
200
157
  const found = new Map();
@@ -257,53 +214,24 @@ function treeFindings({ repoRoot, resolver }) {
257
214
  return findings;
258
215
  }
259
216
 
217
+ /** A side-relative path (or null) as an app-relative one. */
218
+ const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
219
+
260
220
  /**
261
- * The check of one repository. `declaration` overrides hfs.json (a dry run over a repository that has none); `files`
262
- * overrides git ls-files (specs). `only` (a list of paths) limits the per-path checks (slot, pin, size) to those paths; the
263
- * checks of the tree as a whole (required files, minimum instances, empty directories, untracked entries) are not
264
- * per-path. `tree: false` skips the file-system checks (empty directories, ghosts, untracked); they also do not run
265
- * over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the same repository (the
266
- * managed files), judged and counted with this module's own. Returns {ok, profile, apps, manifest, tracked, findings,
267
- * counts}; a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never
268
- * an exception.
221
+ * The findings of one scope: the app root (profile app; its own files, and the rules of the files only the root holds: the one
222
+ * package.json and lockfile, CI, hooks, .starciwork) or one side (profile be or fe; the side folder is `repoRoot` and every path is
223
+ * relative to it, exactly as the standalone repository root was). `files` are the scope's tracked paths; `all` (root only) every
224
+ * tracked path of the app, for the rules that read across it (dependency skew, proof commands).
269
225
  */
270
- export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }) }) {
271
- const why = readWhy(root);
272
- let repo;
273
- try {
274
- repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
275
- } catch (error) {
276
- if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
277
- const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
278
- return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
279
- }
280
- const resolver = createSlotResolver(manifest, repo);
281
- const tracked = files ?? trackedFiles(repoRoot);
282
- const trackedSet = new Set(tracked);
283
- const present = (p) => (p.endsWith('/') ? tracked.some((f) => f.startsWith(p)) : trackedSet.has(p));
284
- const findings = [];
285
- const scoped = only ? new Set(only) : null;
226
+ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, scoped }) {
286
227
  const inScope = (file) => !scoped || scoped.has(file);
228
+ // A required directory of the root (be/, fe/) is present through the files below it, which are the sides' own.
229
+ const trackedSet = new Set(all);
230
+ const present = (p) => (p.endsWith('/') ? all.some((f) => f.startsWith(p)) : trackedSet.has(p));
231
+ const findings = [];
232
+ const isRoot = repo.profile === APP_SCOPE;
287
233
 
288
- for (const file of tracked) {
289
- if (!inScope(file)) continue;
290
- if (repo.profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
291
- const c = resolver.classifyPath(file);
292
- if (c.status === 'no-slot') {
293
- findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
294
- } else if (c.status === 'ambiguous') {
295
- findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
296
- } else if (c.status === 'not-enabled') {
297
- findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
298
- } else if (c.status === 'forbidden') {
299
- const slot = resolver.slot(c.slot);
300
- if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
301
- const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
302
- findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
303
- } else if (c.tracking === 'ignored') {
304
- findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
305
- }
306
- }
234
+ findings.push(...pathFindings({ files: files.filter(inScope), resolver, profile: repo.profile }));
307
235
 
308
236
  const required = resolver.requiredPaths();
309
237
  const missing = new Set();
@@ -314,46 +242,104 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
314
242
  const app = appOf(p);
315
243
  findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
316
244
  };
317
- for (const entry of required.paths) missingFile(entry.slot, entry.path, entry.via);
318
- const instances = instancesOf(resolver, tracked);
245
+ // The app's own required paths; each side reports its own (the resolver of the app lists them too, with their side).
246
+ for (const entry of required.paths) if (!entry.side) missingFile(entry.slot, entry.path, entry.via);
247
+ const instances = instancesOf(resolver, files);
319
248
  for (const instance of instances) for (const p of requiredOf(resolver.slot(instance.slot), instance)) missingFile(instance.slot, p, 'requires');
320
- for (const { slot, min, appKind } of required.minimums) {
321
- if (appKind !== undefined) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
249
+ for (const { slot, min, appKind, side } of required.minimums) {
250
+ if (appKind !== undefined || side) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
322
251
  const count = instances.filter((i) => i.slot === slot).length;
323
252
  if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
324
253
  }
325
254
 
326
255
  const pins = readPins(root);
327
- findings.push(...pinFindings({ repoRoot, files: tracked, profile: repo.profile, pins, only: scoped }));
256
+ findings.push(...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }));
328
257
 
329
- // The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-repository, cheap, no tool run.
330
- const secrets = secretFindings({ repoRoot, files: tracked.filter(inScope), resolver });
331
- const declared = declaration === undefined ? readJson(repoRoot, 'hfs.json') : declaration;
332
- if (repo.profile === 'be') findings.push(...sourceFormFindings({ files: tracked.filter(inScope), resolver }));
258
+ // The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-scope, cheap, no tool run.
333
259
  findings.push(
334
- ...secrets,
335
- ...depFindings({ repoRoot, files: tracked }),
336
- ...pipelineFindings({ repoRoot, files: tracked, pins }),
337
- ...contractFindings({ repoRoot, files: tracked, repo, resolver, stacks: declared?.stacks }),
338
- ...repoLocalCheckFindings({ repoRoot, files: tracked }),
339
- ...lintSuppressionFindings({ repoRoot, files: tracked }),
340
- ...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...specPlacementFindings({ files: tracked, resolver }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
341
- ...extraFindings,
260
+ ...secretFindings({ repoRoot, files: files.filter(inScope), resolver }),
261
+ ...repoLocalCheckFindings({ repoRoot, files }),
262
+ ...lintSuppressionFindings({ repoRoot, files }),
342
263
  );
264
+ if (isRoot) {
265
+ findings.push(
266
+ ...depFindings({ repoRoot, files: all }),
267
+ ...peerIntegrationFindings({ repoRoot, files: all }),
268
+ ...pipelineFindings({ repoRoot, files, pins }),
269
+ ...testTopologyFindings({ repoRoot, files }),
270
+ ...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
271
+ ...appFrontendFindings({ repoRoot, files: all, repo }),
272
+ // The tree check of the app root the machine runs per side for a side folder: README, root entries, automatic gates, hooks path.
273
+ ...checkAppRoot({ root: repoRoot, resolver, tree: trackedTreeView(all) }).violations.map((item) => ({ code: item.ruleId, level: 'error', path: item.path, line: item.line, column: item.column, source: 'machine', message: `${item.path}: ${item.message}` })),
274
+ );
275
+ } else if (repo.profile === 'be') {
276
+ findings.push(...contractFindings({ files, repo, resolver }), ...stacksFindings({ repoRoot, files, resolver }), ...testTopologyFindings({ repoRoot, files }));
277
+ } else {
278
+ findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter(inScope) }));
279
+ }
343
280
 
344
- const soft = resolver.ruleParams().fileLines.soft;
345
- for (const file of tracked) {
346
- if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
347
- let lines;
348
- try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
349
- if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
281
+ if (!isRoot) {
282
+ const soft = resolver.ruleParams().fileLines.soft;
283
+ for (const file of files) {
284
+ if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
285
+ let lines;
286
+ try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
287
+ if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
288
+ }
350
289
  }
290
+ return findings;
291
+ }
351
292
 
293
+ /**
294
+ * The check of one app. `repoRoot` is the app root (a side folder is checked as that side alone). `declaration` overrides hfs.json
295
+ * (a dry run over an app that has none); `files` overrides git ls-files (specs), app-relative. `only` (a list of app-relative paths)
296
+ * limits the per-path checks (slot, pin, size) to those paths; the checks of the tree as a whole (required files, minimum instances,
297
+ * empty directories, untracked entries) are not per-path. `tree: false` skips the file-system checks (empty directories, ghosts,
298
+ * untracked); they also do not run over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the
299
+ * same app (the managed files, the formatter, the contract emit), app-relative, judged and counted with this module's own.
300
+ * The root scope and each side run their own rules over their own files (hfs checks per side, the side folder as the old repository
301
+ * root, plus the root checks); every finding path is app-relative. Returns {ok, profile, apps, manifest, tracked, findings, counts};
302
+ * a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never an exception.
303
+ */
304
+ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }), surface = 'all' }) {
305
+ const why = readWhy(root);
306
+ let repo;
307
+ try {
308
+ repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
309
+ } catch (error) {
310
+ if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
311
+ const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
312
+ return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
313
+ }
314
+ const resolver = createSlotResolver(manifest, repo);
315
+ const tracked = files ?? trackedFiles(repoRoot);
316
+ const scoped = only ? new Set(only) : null;
317
+ const findings = [];
318
+ // `hfs check` leaves to the lint canon the per-path findings that sit on an existing TypeScript file (hfs-path-findings.mjs).
319
+ const keep = (list, scopeRoot) => (surface === 'check' ? list.filter((finding) => !(finding.origin === 'repo' && onLintSurface(scopeRoot, finding))) : list);
320
+ if (repo.profile === APP_SCOPE) {
321
+ const own = tracked.filter((file) => resolver.sideOf(file) === null);
322
+ findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: own, all: tracked, scoped }), repoRoot));
323
+ for (const side of SIDES) {
324
+ const prefix = `${side}/`;
325
+ const sideRoot = path.join(repoRoot, side);
326
+ const sideFiles = tracked.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length));
327
+ const sideScoped = scoped ? new Set([...scoped].filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length))) : null;
328
+ const sideFindings = scopeFindings({ repoRoot: sideRoot, root, repo: repo.sides[side], resolver: resolver.sides[side], files: sideFiles, scoped: sideScoped });
329
+ const message = appRelativeMessages(side, sideRoot);
330
+ findings.push(...keep(sideFindings, sideRoot).map((finding) => ({ ...finding, side, path: onSide(side, finding.path), message: message(finding.message) })));
331
+ }
332
+ } else {
333
+ findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: tracked, scoped }), repoRoot));
334
+ }
335
+ findings.push(...extraFindings);
352
336
  if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
353
337
 
354
- const finished = withWhy(findings, why);
338
+ // The app-root tree check reports machine codes: their why is read with the check's own.
339
+ const finished = withWhy(findings, { ...why, ...readWhy(root, [...new Set(findings.map((f) => f.code).filter((code) => !why[code]))]) });
355
340
  const counts = summarize(finished);
356
- return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps: repo.apps, tracked: tracked.length, findings: finished, counts };
341
+ const apps = repo.profile === APP_SCOPE ? SIDES.flatMap((side) => repo.sides[side].apps.map((app) => ({ ...app, side }))) : repo.apps;
342
+ return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps, tracked: tracked.length, findings: finished, counts };
357
343
  }
358
344
 
359
345
  // ------------------------------------------------------------------------------------------ the whole check
@@ -390,45 +376,66 @@ function machineFindings(report) {
390
376
  }
391
377
 
392
378
  /**
393
- * The whole `hfs check` of one repository: checkRepo() (slots, pins, size, tree) and then the architecture machine over the
394
- * same work tree, its violations and errors merged in as findings with the Vietnamese why of their codes. `fast` judges
395
- * only what changed since the merge-base (`base` names another ref): the per-path slot and pin checks on the changed
396
- * paths, the machine on the owners of the changed source files without clones and dead exports, and no file-system tree
397
- * checks. `extraFindings` are the findings of the emitters that render templates or run the repository's formatter
398
- * (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing merge-base under `fast` is an Error, never a silent full pass.
379
+ * The whole `hfs check` of one app: checkRepo() (the root and both sides: slots, pins, size, tree) and then the architecture machine
380
+ * over each side folder (the side as the repository root it judges), its violations and errors merged in as findings with the
381
+ * Vietnamese why of their codes and the side prefixed onto their paths. `fast` judges only what changed since the merge-base (`base`
382
+ * names another ref): the per-path slot and pin checks on the changed paths, the machine on the owners of the changed source files
383
+ * without clones and dead exports, and no file-system tree checks. `extraFindings` are the findings of the emitters that render
384
+ * templates or run the formatter (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing
385
+ * merge-base under `fast` is an Error, never a silent full pass. `repoRoot` may also be a side folder: that side alone is checked.
399
386
  */
400
387
  export function checkRepository({ repoRoot, root = skillRoot, fast = false, base, extraFindings = [], manifest = loadSlotManifest({ root }), machine = checkArchitecture }) {
401
388
  const changed = fast ? changedSince(repoRoot, base) : null;
402
- const baseSha = changed ? changed.base : (base ? (mergeBaseOf(repoRoot, base) ?? refuse('HFS_REPO_UNREADABLE', `--base ${base} has no merge-base with HEAD`, { repoRoot, base })) : undefined);
403
- const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, ...(changed ? { only: changed.files, tree: false } : {}) });
389
+ const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, surface: 'check', ...(changed ? { only: changed.files, tree: false } : {}) });
404
390
  if (slotResult.profile === null) return { ...slotResult, machine: { status: 'skipped', reason: 'hfs.json is not valid' } };
405
-
406
- let paths;
407
- if (changed) {
408
- const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, repoRoot));
409
- paths = [...new Set(changed.files.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
410
- if (!paths.length) return { ...slotResult, machine: { status: 'skipped', reason: 'no changed source file', base: changed.base }, fast: { base: changed.base, changed: changed.files.length } };
411
- }
412
- let report;
413
- try {
414
- report = machine({ repositoryRoot: repoRoot, base: baseSha, ...(changed ? { paths, fast: true } : {}) });
415
- } catch (error) {
416
- report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
391
+ const repo = readRepoDeclaration(manifest, repoRoot);
392
+ const scopes = repo.profile === APP_SCOPE
393
+ ? SIDES.map((side) => ({ side, repoRoot: path.join(repoRoot, side), prefix: `${side}/` }))
394
+ : [{ side: null, repoRoot, prefix: '' }];
395
+ const runs = [];
396
+ const found = [];
397
+ for (const scope of scopes) {
398
+ const run = machineOver({ scope, manifest, machine, changed });
399
+ runs.push(run.info);
400
+ found.push(...run.findings);
417
401
  }
418
- const found = machineFindings(report);
419
402
  const why = readWhy(root, [...new Set(found.map((f) => f.code))]);
420
403
  const findings = [...slotResult.findings, ...withWhy(found, why)];
421
404
  const counts = summarize(findings);
405
+ const ran = runs.filter((info) => info.status === 'ran');
422
406
  return {
423
407
  ...slotResult,
424
408
  ok: counts.error === 0,
425
409
  findings,
426
410
  counts,
427
- machine: { status: 'ran', files: report.files, kinds: report.kinds, ...(changed ? { paths, base: changed.base } : {}) },
411
+ machine: ran.length
412
+ ? { status: 'ran', files: ran.reduce((sum, info) => sum + info.files, 0), kinds: [...new Set(ran.flatMap((info) => info.kinds ?? []))], sides: runs, ...(changed ? { paths: ran.flatMap((info) => info.paths ?? []), base: changed.base } : {}) }
413
+ : { status: 'skipped', reason: runs.map((info) => info.reason).join('; '), sides: runs, ...(changed ? { base: changed.base } : {}) },
428
414
  ...(changed ? { fast: { base: changed.base, changed: changed.files.length } } : {}),
429
415
  };
430
416
  }
431
417
 
418
+ /** The architecture machine over one scope (a side folder): `{ info, findings }`, finding paths app-relative. */
419
+ function machineOver({ scope, manifest, machine, changed }) {
420
+ const { side, prefix } = scope;
421
+ const label = side ?? 'repository';
422
+ let paths;
423
+ if (changed) {
424
+ const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, scope.repoRoot));
425
+ const mine = changed.files.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
426
+ paths = [...new Set(mine.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
427
+ if (!paths.length) return { info: { side, status: 'skipped', reason: `no changed source file in ${label}` }, findings: [] };
428
+ }
429
+ let report;
430
+ try {
431
+ report = machine({ repositoryRoot: scope.repoRoot, base: changed?.base, surface: 'check', ...(changed ? { paths, fast: true } : {}) });
432
+ } catch (error) {
433
+ report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
434
+ }
435
+ const message = side ? appRelativeMessages(side, scope.repoRoot) : null;
436
+ const findings = machineFindings(report).map((finding) => (side ? { ...finding, side, message: message(finding.message), ...(finding.path ? { path: onSide(side, finding.path) } : {}) } : finding));
437
+ return { info: { side, status: 'ran', files: report.files, kinds: report.kinds, ...(paths ? { paths: paths.map((p) => `${prefix}${p}`) } : {}) }, findings };
438
+ }
432
439
  // --------------------------------------------------------------------------------------------------- explain
433
440
 
434
441
  const TEST_KIND = {
@@ -450,7 +457,9 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
450
457
  const slot = resolver.slot(c.slot);
451
458
  const tier = resolver.tierOf(c.path);
452
459
  const owner = resolver.ownerOf(c.path);
453
- const mayImport = tier && tier !== 'none' ? resolver.allowedImports(tier) : null;
460
+ // A path of a side imports by its side's direction matrix; the app root has none of its own.
461
+ const scope = c.side ? resolver.sides[c.side] : resolver;
462
+ const mayImport = tier && tier !== 'none' ? scope.allowedImports(tier) : null;
454
463
  return {
455
464
  path: c.path,
456
465
  status: c.status,
@@ -471,61 +480,3 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
471
480
  ...(c.status === 'not-enabled' ? { code: 'HFS_SLOT_NOT_ENABLED', titleVi: why.HFS_SLOT_NOT_ENABLED.titleVi, whyVi: why.HFS_SLOT_NOT_ENABLED.whyVi } : {}),
472
481
  };
473
482
  }
474
-
475
- // ------------------------------------------------------------------------------------------------- init
476
-
477
- const SLUG_SUFFIX = /-(backend|be|frontend|fe|api|web|app)$/;
478
- const exists = (p) => fs.existsSync(p);
479
- const readPackage = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } };
480
- const depsOf = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
481
-
482
- /**
483
- * A starter hfs.json by detection: profile from the dependencies (next -> fe, @nestjs/core -> be, over the root and
484
- * every apps/<name>), apps from the apps/<name> directories (fe: kind next; be: worker/migrate/cli by name, else api).
485
- * optionalSlots are the opt-in slots (not implied by an app kind) that tracked files already occupy. Connections are not guessed; a repository that keeps a database declares them by hand and gains the migrate app.
486
- * A repository the detection cannot classify is HFS_INIT_UNDETECTED, never a guess.
487
- */
488
- export function detectDeclaration({ repoRoot, manifest }) {
489
- const appsDir = path.join(repoRoot, 'apps');
490
- const names = isDir(appsDir) ? fs.readdirSync(appsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules').map((e) => e.name).sort() : [];
491
- const rootPkg = readPackage(repoRoot);
492
- const all = { ...depsOf(rootPkg) };
493
- for (const name of names) Object.assign(all, depsOf(readPackage(path.join(appsDir, name))));
494
- const isFe = 'next' in all || names.some((n) => ['next.config.ts', 'next.config.js', 'next.config.mjs'].some((f) => exists(path.join(appsDir, n, f))));
495
- const isBe = '@nestjs/core' in all || exists(path.join(repoRoot, 'nest-cli.json'));
496
- if (isFe === isBe) refuse('HFS_INIT_UNDETECTED', `cannot tell the profile of ${repoRoot}: ${isFe ? 'both next and Nest are present' : 'neither next nor @nestjs/core is declared'}`, { repoRoot });
497
- const profile = isFe ? 'fe' : 'be';
498
- const apps = names.map((name) => {
499
- if (profile === 'fe') return { name, kind: 'next' };
500
- if (!isDir(path.join(appsDir, name, 'src'))) return null;
501
- const kind = /migrat/.test(name) ? 'migrate' : (/worker/.test(name) ? 'worker' : (/(^|-)cli($|-)/.test(name) ? 'cli' : 'api'));
502
- return { name, kind };
503
- }).filter(Boolean).filter((a) => manifest.appKinds[profile].includes(a.kind));
504
- if (!apps.length) refuse('HFS_INIT_UNDETECTED', `${repoRoot} has no apps/<name> ${profile === 'fe' ? 'Next application' : 'Nest application with a src directory'} to declare`, { repoRoot, profile });
505
- const project = String(rootPkg?.name ?? path.basename(repoRoot)).replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(SLUG_SUFFIX, '');
506
- const declaration = { hfs: manifest.major, profile, project: /^[a-z]/.test(project) ? project : `p-${project}`, apps };
507
- const optionalSlots = occupiedOptInSlots({ repoRoot, manifest, declaration });
508
- return optionalSlots.length ? { ...declaration, optionalSlots } : declaration;
509
- }
510
-
511
- /** The opt-in slots (not enabled by an app kind) that at least one tracked file falls in, found by enabling them all for a look. */
512
- function occupiedOptInSlots({ repoRoot, manifest, declaration }) {
513
- let files;
514
- try { files = trackedFiles(repoRoot); } catch (error) { if (error.code === 'HFS_REPO_UNREADABLE') return []; throw error; }
515
- const optIn = manifest.slots.filter((s) => s.profiles.includes(declaration.profile) && s.presence === 'opt-in' && s.appKind === undefined).map((s) => s.id);
516
- const everything = createSlotResolver(manifest, resolveRepoDeclaration(manifest, { ...declaration, optionalSlots: optIn }));
517
- const used = new Set();
518
- for (const file of files) { const c = everything.classifyPath(file); if (c.status === 'owned' && optIn.includes(c.slot)) used.add(c.slot); }
519
- return optIn.filter((id) => used.has(id));
520
- }
521
-
522
- /** Write hfs.json unless one exists (HFS_INIT_EXISTS); `write: false` returns the text only. */
523
- export function initRepo({ repoRoot, root = skillRoot, write = true, manifest = loadSlotManifest({ root }) }) {
524
- const file = path.join(repoRoot, HFS_DECLARATION_FILE);
525
- if (write && exists(file)) refuse('HFS_INIT_EXISTS', `${file} already exists; init never rewrites a declaration`, { file });
526
- const declaration = detectDeclaration({ repoRoot, manifest });
527
- resolveRepoDeclaration(manifest, declaration);
528
- const text = `${JSON.stringify(declaration, null, 2)}\n`;
529
- if (write) fs.writeFileSync(file, text);
530
- return { file, declaration, text, written: write };
531
- }
@@ -0,0 +1,95 @@
1
+ // hfs-path-findings.mjs - the per-path judgements of the slot manifest: what a tracked path is. One function, two surfaces: `hfs check`
2
+ // (checkRepo) runs it over the tracked tree and keeps the findings that have no TypeScript file to sit on; the lint canon's project graph
3
+ // (scripts/lib/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
4
+ // HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
5
+ // BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
6
+ import path from 'node:path';
7
+ import { allowsFile } from './hfs-allows.mjs';
8
+ import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
9
+ import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
10
+ import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
11
+ import { APP_SCOPE } from './hfs-slots.mjs';
12
+
13
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+ const SOURCE_ROOT = /^(?:src|apps)\//;
15
+ const FREE_NAMES = new Set(['index.ts', 'main.ts']);
16
+ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
17
+ /**
18
+ * Where the work of a banned data-access suffix goes. The suffix is banned by the manifest (ruleParams.be.bannedSuffixes);
19
+ * this only adds the convention's home to the finding, so a file that is a repository or a store is told what replaces it.
20
+ */
21
+ const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
22
+ const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
23
+
24
+ /**
25
+ * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
26
+ * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
27
+ * as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
28
+ * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
29
+ */
30
+ function sourceFormFindings({ files, resolver }) {
31
+ const { suffixes, bannedSuffixes } = resolver.ruleParams();
32
+ // A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
33
+ // anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
34
+ const boundSuffixes = new Map();
35
+ for (const slot of resolver.slots()) {
36
+ const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
37
+ if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
38
+ }
39
+ const findings = [];
40
+ for (const file of files) {
41
+ if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
42
+ const c = resolver.classifyPath(file);
43
+ if (c.status !== 'owned' || c.tracking === 'ignored') continue;
44
+ const base = path.posix.basename(file);
45
+ if (FREE_NAMES.has(base)) continue;
46
+ // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
47
+ const slot = resolver.slot(c.slot);
48
+ if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
49
+ // So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
50
+ const admitted = allowsFile(resolver, file);
51
+ if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
52
+ // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
53
+ if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
54
+ if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
55
+ const parts = base.slice(0, -'.ts'.length).split('.');
56
+ const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
57
+ if (banned) {
58
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
59
+ } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
60
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
61
+ } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
62
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
63
+ }
64
+ }
65
+ return findings;
66
+ }
67
+
68
+ /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
69
+ export function pathFindings({ files, resolver, profile }) {
70
+ const findings = [];
71
+ for (const file of files) {
72
+ if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
73
+ const c = resolver.classifyPath(file);
74
+ if (c.status === 'no-slot') {
75
+ findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
76
+ } else if (c.status === 'ambiguous') {
77
+ findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
78
+ } else if (c.status === 'not-enabled') {
79
+ findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
80
+ } else if (c.status === 'forbidden') {
81
+ const slot = resolver.slot(c.slot);
82
+ if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
83
+ const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
84
+ findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
85
+ } else if (c.tracking === 'ignored') {
86
+ findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
87
+ }
88
+ }
89
+
90
+ if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
91
+ // The app root (scripts/, CI, hooks) holds no test layer: a spec there is the same finding as a spec of an operational script.
92
+ else if (profile === APP_SCOPE) findings.push(...specPlacementFindings({ files, resolver }));
93
+ // The lint canon's project graph serves these findings on a TypeScript file; the origin keeps them apart from the machine's.
94
+ return findings.map((finding) => ({ ...finding, origin: 'repo' }));
95
+ }