@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
@@ -32,13 +32,14 @@ newCode:
32
32
  metric: new_security_hotspots_reviewed
33
33
  minReviewedPercent: 100
34
34
  unreviewedMax: 0
35
- # The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE pipeline
36
- # (packages/hfs, contract change hfs-sonar-import) and the gate holds the open-issue count of the whole project at zero:
37
- # starci-hfs `hfs check --sonar reports/hfs.sonar.json` (repository, managed-file and architecture-machine findings;
38
- # the rule id is the finding code) -> sonar.externalIssuesReportPaths
39
- # eslint `eslint -f json -o reports/eslint.json` (the BE and FE canon plugins alike) converted by `hfs report eslint` -> sonar.externalIssuesReportPaths
40
- # stylelint `stylelint --formatter json` converted by `hfs report stylelint` -> sonar.externalIssuesReportPaths
41
- # The three imports share ONE placement rule (a finding on a file Sonar does not index is filed on the first source file); Sonar's own
35
+ # The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `hfs lint`
36
+ # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry), which writes ONE file, reports/lint.sonar.json
37
+ # (`npm run lint -- --sonar reports/lint.sonar.json`), named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
38
+ # the whole project at zero. The file carries three engines:
39
+ # starci-hfs the repository findings of `hfs check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
40
+ # eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
41
+ # stylelint stylelint's json over the style glob (front ends)
42
+ # The engines share ONE placement rule (a finding on a file Sonar does not index is filed on the first source file); Sonar's own
42
43
  # ESLint import (sonar.eslint.reportPaths) is not used because it drops such issues.
43
44
  # A SonarQube gate condition cannot filter issues by engine, so the condition is the project's open issues (`violations`,
44
45
  # overall code): the three engines above are the imports it counts, and every native Sonar issue counts with them. That is
@@ -245,10 +245,9 @@ BE_APP_BUSINESS_ROLE:
245
245
  BE_APP_COMPOSITION_ONLY:
246
246
  title: "Composition root has stray source"
247
247
  title_vi: "Gốc ghép nối có tệp lạc chỗ"
248
- meaning_vi: "Mã trong ứng dụng gốc không phải tệp mà slot ứng dụng cho phép (main.ts, app.module.ts, <app>.options.ts)."
248
+ meaning_vi: "Mã trong ứng dụng gốc không phải tệp mà slot ứng dụng cho phép (main.ts, app.module.ts, <app>.options.ts; operations.ts ở ứng dụng API)."
249
249
  causes_vi:
250
250
  - "Thêm tệp không đúng tên vào thư mục ứng dụng gốc"
251
- - "Composition spec của app tự khai AppModule, import AppModule từ tệp khác app.module, hoặc không gọi AppModule.register( — module thật không bao giờ được khởi chạy"
252
251
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra; không cần ai can thiệp thêm."
253
252
  owner: op-retry
254
253
  kind: check-finding
@@ -445,6 +444,7 @@ BE_RAW_INJECT:
445
444
  - "Token là chuỗi thay vì unique symbol"
446
445
  - "Tiêm lớp hạ tầng (CacheService, AppLogger) không qua injector"
447
446
  - "Dùng ModuleRef.get hoặc forwardRef để né vòng phụ thuộc"
447
+ - "constructor(private readonly timeoutMs: number) {} hoặc ReadonlyMap<string, X> không có Inject<Thing>() gắn token có tên"
448
448
  nextStep_vi: "Khai báo token `export const THING: unique symbol = Symbol(\"<owner>.<thing>\")` và `export const InjectThing = (): TypedParameterDecorator<Thing> => injector<Thing>(THING)` trong <owner>.decorators.ts rồi dùng @InjectThing() ở mọi nơi; vòng phụ thuộc là lỗi kiến trúc cần gỡ, không dùng forwardRef. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra và mục của luật trong BE-CONVENTION; không thêm ngoại lệ, không tắt luật."
449
449
  owner: op-retry
450
450
  kind: check-finding
@@ -484,8 +484,9 @@ BE_SCHEMA_OWNER:
484
484
  BE_SOURCE_FORM:
485
485
  title: "Back-end source file breaks the naming or form canon"
486
486
  title_vi: "Tệp nguồn BE sai quy ước tên hoặc hình thức"
487
- meaning_vi: "Tên tệp phải là kebab-case với hậu tố vai trò trong danh sách đóng của slots.yaml; chỉ export có tên (không export default); mọi export và thành viên công khai có JSDoc tiếng Anh; không emoji, và không có chữ tiếng Việt trong định danh, chuỗi ký tự, chú thích hay tên test ngoài catalog messages/ và slot fixture i18n."
487
+ meaning_vi: "Tên tệp phải là kebab-case với hậu tố vai trò trong danh sách đóng của slots.yaml; chỉ export có tên (không export default); mọi export và thành viên công khai có JSDoc tiếng Anh; không emoji, và không có chữ tiếng Việt trong định danh, chuỗi ký tự, chú thích hay tên test ngoài catalog messages/ và slot fixture i18n; mã production không khai báo lớp, hàm hay hằng export có từ Mock, Fake hoặc Stub trong tên (đồ giả nằm trong spec hoặc src/tests; driver ngoại tuyến là adapter điều khiển bằng options, đặt tên theo việc nó làm)."
488
488
  causes_vi:
489
+ - "class MockExpertAgentService trong mã production thay vì adapter điều khiển bằng options"
489
490
  - "Tệp dùng hậu tố bị cấm (use-case, repository, store, util, helper, types, constants)"
490
491
  - "Lớp export không kết thúc bằng hậu tố vai trò của tệp và cũng không kết thúc bằng tên port (interface trong *.port.ts hoặc *.contracts.ts) mà nó implements"
491
492
  - "Interface trong tệp *.input.ts, *.request.ts, *.response.ts, *.type.ts, *.args.ts hoặc *.rows.ts không kết thúc bằng Input, Request, Response, Type, Args hoặc Row"
@@ -516,6 +517,7 @@ BE_SPEC_QUALITY:
516
517
  - "Spec chỉ có toHaveBeenCalled*, không khẳng định kết quả hay trạng thái"
517
518
  - "`it.skip`, `describe.skip`, `xit`, `it.todo`, `.skipIf`/`.runIf` hoặc `(cond ? describe : describe.skip)` trong spec; hãy viết test thật hoặc xóa nó, còn việc bỏ qua khi thiếu sandbox nằm trong contract helper của world (`sandbox.describe(...)`)"
518
519
  - "e2e gọi CommandBus, QueryBus hay handler trực tiếp; dùng sleep thay cho waitFor; tự gọi Test.createTestingModule; import SDK của nhà cung cấp mô hình; không đọc lại dòng đã lưu qua EntityManager"
520
+ - "Spec đơn vị của service khẳng định `expect.any(String)`, `expect.any(Number)` hoặc `expect.any(Date)` cho giá trị service tự sinh; service nhận bộ sinh id và đồng hồ qua token, spec cấp `fakeIds()` và `new FakeClock(...)` từ @starci/jest-preset rồi khẳng định đúng giá trị"
519
521
  - "Spec đơn vị của service có providers khác với phụ thuộc constructor: thừa nhà cung cấp, thiếu nhà cung cấp, service không đứng đầu, token không suy ra được hoặc nhà cung cấp không phải lớp hay { provide, useValue }"
520
522
  nextStep_vi: "Chuyển luật kiến trúc sang lint hoặc bộ kiểm tra; khẳng định kết quả hoặc trạng thái; e2e vào bằng transport thật, chờ bằng waitFor và dựng thế giới ở src/tests/e2e/setup. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra."
521
523
  owner: op-retry
@@ -539,7 +541,7 @@ BE_TEST_TOPOLOGY:
539
541
  meaning_vi: "Cấu hình test lệch chuẩn: `<detail>`."
540
542
  causes_vi:
541
543
  - "Vi phạm luật R47: một `jest.config.js` (preset `@starci/jest-preset`) với bốn project `unit`, `integration`, `e2e`, `contract`; thư mục và hậu tố spec luôn khớp nhau; `diagnostics: false` + `isolatedModules: true`; `test:integration`, `test:e2e`, `test:contract` = `typecheck:tests && jest --selectProjects <tên>`; `src/tests/world/` là nơi duy nhất dựng hạ tầng test và chỉ chứa `global-setup.ts`, `global-teardown.ts`, `use-test-world.ts`, `fakes/` và file gốc có hậu tố vai trò (kể cả `test-world.config.ts`)."
542
- - "Vi phạm luật R47 về stack thật: mọi dịch vụ khai trong `.starcistacks/<env>` (Postgres, Redis, Keycloak, mail host, k3s...) chạy THẬT trong world qua toxiproxy, world đọc dịch vụ và phiên bản image từ stack lúc chạy; `fakes/<provider>/` chỉ giả SaaS bên ngoài mà đội không vận hành. Ngoại lệ duy nhất: dịch vụ stateless cần phần cứng đặc biệt hoặc model bên ngoài (GPU inference, embedding tự host) được giả khi `test-world.config.ts` khai trong `fakedBy` với `reason` không rỗng; dịch vụ có trạng thái (database, cache, identity, storage, mail, queue, search hoặc có volume bền) không bao giờ được giả, và mục `fakedBy` trỏ tới dịch vụ hoặc thư mục fake không tồn tại bị coi là cũ."
544
+ - "Vi phạm luật R47 về stack thật: mọi dịch vụ khai trong `.starcistacks/<env>` (Postgres, Redis, Keycloak, mail host, k3s...) chạy THẬT trong world qua toxiproxy, world đọc dịch vụ và phiên bản image từ stack lúc chạy; `fakes/<provider>/` chỉ giả SaaS bên ngoài mà đội không vận hành. Ngoại lệ duy nhất: dịch vụ stateless cần phần cứng đặc biệt hoặc model bên ngoài (GPU inference, embedding tự host) được giả khi mục `stacks` của dịch vụ đó trong `test-world.config.ts` khai `{ fakedBy, reason }` với `reason` không rỗng; dịch vụ có trạng thái (database, cache, identity, storage, mail, queue, search hoặc có volume bền) không bao giờ được giả, và mục `fakedBy` trỏ tới dịch vụ hoặc fake (mục `fakes` hay thư mục `fakes/`) không tồn tại bị coi là cũ. `test-world.config.ts` khai world theo đúng một dạng export có tên `export const { useTestWorld, useSandbox } = defineTestWorld({...})` (`stack`, `stacks` theo từng dịch vụ, `fakes`); export default không được đọc và bị báo lỗi."
543
545
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra (nợ hàng loạt có codemod của HFS); không cần ai can thiệp thêm."
544
546
  owner: op-retry
545
547
  kind: check-finding
@@ -905,11 +907,11 @@ FE_TRANSPORT_OWNER:
905
907
  kind: check-finding
906
908
 
907
909
  FE_WIRE_GENERATED:
908
- title: "Wire types generated from the contract copy"
910
+ title: "Wire types generated from the be contract snapshots the fe side reads"
909
911
  title_vi: "Kiểu wire gõ tay"
910
- meaning_vi: "`<file>` tự gõ kiểu wire/ép kiểu phản hồi. Dùng kiểu sinh từ `contract/`."
912
+ meaning_vi: "`<file>` tự gõ kiểu wire/ép kiểu phản hồi, hoặc kiểu sinh ra cũ hơn ảnh chụp hợp đồng. Dùng kiểu sinh từ `be/contracts/`."
911
913
  causes_vi:
912
- - "Vi phạm luật R52: Kiểu wire sinh từ bản hợp đồng (`codegen`); tài liệu GraphQL trong `.graphql`; cấm `as GraphqlResult<T>`; enum wire không phải `string`."
914
+ - "Vi phạm luật R52: Kiểu wire sinh từ ảnh chụp hợp đồng của be (`be/contracts/`, chạy `npm run codegen` ở gốc app); tài liệu GraphQL trong `.graphql`; cấm `as GraphqlResult<T>`; enum wire không phải `string`."
913
915
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra và mục của luật trong tiêu chuẩn HFS; nợ cũ cần quyết định thiết kế nên op không thêm ngoại lệ, chỉ tách hoặc chuyển về đúng chủ."
914
916
  owner: op-retry
915
917
  kind: check-finding
@@ -981,21 +983,21 @@ HFS_CANON_PIN_DRIFT:
981
983
  kind: check-finding
982
984
 
983
985
  HFS_CI_MISSING_CANON:
984
- title: "CI runs the pinned `@starci/hfs check`; pre-push runs typecheck and lint"
986
+ title: "CI runs the pinned `hfs lint`; pre-push runs typecheck and lint"
985
987
  title_vi: "CI thiếu bước canon"
986
988
  meaning_vi: "Workflow/husky thiếu bước `<step>`. Cấu trúc chỉ được kiểm khi agent chạy — CI phải tự kiểm."
987
989
  causes_vi:
988
- - "Vi phạm luật R13: CI phải chạy `npx @starci/hfs check` (bản pin); pre-push phải có `typecheck` + `lint:check`."
990
+ - "Vi phạm luật R13: CI phải chạy `hfs lint` (`npm run lint` hoặc `npx @starci/hfs lint`, bản pin); pre-push phải có `typecheck` + `lint`."
989
991
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra (nợ hàng loạt có codemod của HFS); không cần ai can thiệp thêm."
990
992
  owner: op-retry
991
993
  kind: check-finding
992
994
 
993
995
  HFS_CONTRACT_SNAPSHOT_DRIFT:
994
- title: "Contract snapshot equals the emit, the FE copy equals the BE"
996
+ title: "The be side commits its contract snapshot and it equals the emit"
995
997
  title_vi: "Bản hợp đồng lệch bản thật"
996
- meaning_vi: "Hợp đồng `<file>` lệch bản thật (`<sha>` ≠ `<sha>`). Chạy `npm run contract:emit` (BE) / `contract:pull` (FE) rồi commit kết quả. Nếu emit không chạy được thì finding nêu nguyên văn lỗi của emit; không bao giờ bỏ qua âm thầm."
998
+ meaning_vi: "Hợp đồng `<file>` lệch bản thật (`<sha>` ≠ `<sha>`). Chạy `npm run contract:emit` ở gốc app rồi commit kết quả. Nếu emit không chạy được thì finding nêu nguyên văn lỗi của emit; không bao giờ bỏ qua âm thầm."
997
999
  causes_vi:
998
- - "Vi phạm luật R23: BE: `contracts/<app>/schema.graphql` và `contracts/<app>/openapi.json` (sinh từ bảng thao tác có kiểu `apps/<app>/src/operations.ts`) phải bằng bản `hfs emit-contracts` phát ra ngay lúc kiểm tra (đồ thị module đọc từ mã nguồn, không cần env, DB hay mạng); chỉ chạy ở lượt kiểm tra đầy đủ, `--fast` bỏ qua và nói rõ trong phần coverage. FE: `modules/api/contract/*` bằng bản BE (so hash)."
1000
+ - "Vi phạm luật R23: phía be: `be/contracts/<app>/schema.graphql` và `be/contracts/<app>/openapi.json` (sinh từ bảng thao tác có kiểu `apps/<app>/src/operations.ts`) phải bằng bản `hfs emit-contracts` phát ra ngay lúc kiểm tra (đồ thị module đọc từ mã nguồn, không cần env, DB hay mạng); chỉ chạy ở lượt kiểm tra đầy đủ, `--fast` bỏ qua và nói rõ trong phần coverage. Phía fe đọc thẳng `be/contracts/` (đường đọc chéo duy nhất khai trong hfs.json `sides.fe.reads`), không giữ bản sao."
999
1001
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra; không cần ai can thiệp thêm."
1000
1002
  owner: op-retry
1001
1003
  kind: check-finding
@@ -1141,27 +1143,6 @@ HFS_HOOKS_PATH_REDIRECTED:
1141
1143
  owner: op-retry
1142
1144
  kind: check-finding
1143
1145
 
1144
- HFS_INIT_EXISTS:
1145
- title: "hfs.json already exists"
1146
- title_vi: "hfs.json đã có sẵn"
1147
- meaning_vi: "Lệnh hfs init không ghi đè bản khai báo đã có vì bản đó là quyết định của kho."
1148
- causes_vi:
1149
- - "Chạy hfs init trong kho đã có hfs.json"
1150
- nextStep_vi: "Sửa hfs.json trực tiếp, hoặc dùng hfs init --stdout để xem bản dò tự động rồi so sánh."
1151
- owner: op-retry
1152
- kind: verb-refusal
1153
-
1154
- HFS_INIT_UNDETECTED:
1155
- title: "Repository profile or apps not detected"
1156
- title_vi: "Không dò được loại kho hoặc ứng dụng"
1157
- meaning_vi: "Lệnh hfs init không xác định chắc chắn kho là backend hay frontend, hoặc không thấy ứng dụng nào dưới apps/ để khai báo, nên không đoán mò."
1158
- causes_vi:
1159
- - "Không có next hay @nestjs/core trong phụ thuộc, hoặc có cả hai"
1160
- - "Kho chưa có thư mục apps/<tên>/ (Nest cần thêm src/, Next cần next.config)"
1161
- nextStep_vi: "Tạo bố cục apps/<tên>/ theo chuẩn hoặc viết hfs.json bằng tay theo modules/schemas/hfs-repo.schema.yaml."
1162
- owner: op-retry
1163
- kind: verb-refusal
1164
-
1165
1146
  HFS_LINT_SUPPRESSION_FILE:
1166
1147
  title: "A repository keeps no lint-suppression file, script or option"
1167
1148
  title_vi: "Kho có tệp hoặc script ghi nhận bỏ qua lỗi lint"
@@ -1238,6 +1219,16 @@ HFS_PACKAGE_MANAGER_MIXED:
1238
1219
  owner: other-op:backend.scaffold
1239
1220
  kind: check-finding
1240
1221
 
1222
+ HFS_PEER_INTEGRATION_MISSING:
1223
+ title: "An app declares the runtime peer its driver integration needs"
1224
+ title_vi: "Thiếu gói peer mà tích hợp driver cần khi chạy"
1225
+ meaning_vi: "`package.json` ở gốc app phụ thuộc vào các gói của một cặp trong `knowledge/hfs/peer-integrations.yaml` (ví dụ `@nestjs/apollo` trên `@nestjs/platform-express` 11, tức Express 5) nhưng không khai báo gói `<requires>` trong dependencies, nên api không khởi động được."
1226
+ causes_vi:
1227
+ - "Vi phạm luật R111: npm không tự cài gói peer này, type-check và unit spec không nạp nó, nên lỗi chỉ lộ ra khi api khởi động (hai backend cũ thiếu `@as-integrations/express5` và api GraphQL không chạy được trên Express 5)."
1228
+ nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra: thêm gói `<requires>` vào dependencies của `package.json` ở gốc app; không thêm ngoại lệ."
1229
+ owner: op-retry
1230
+ kind: check-finding
1231
+
1241
1232
  HFS_PLAINTEXT_SECRET:
1242
1233
  title: "Secrets exist only as `.starcistacks/<env>/secrets/<slug>.enc`"
1243
1234
  title_vi: "Bí mật dạng rõ trong kho"
@@ -1283,7 +1274,7 @@ HFS_README_DESCRIPTION_INVALID:
1283
1274
  HFS_README_DEVELOPMENT_INCOMPLETE:
1284
1275
  title: "README Development section incomplete"
1285
1276
  title_vi: "Mục Development trong README chưa đủ lệnh"
1286
- meaning_vi: "Mục Development chưa nêu đủ các lệnh cài đặt, typecheck, lint:check, build và test (các script do hfs sync quản lý; test chạy bằng npm test)."
1277
+ meaning_vi: "Mục Development chưa nêu đủ các lệnh cài đặt, typecheck, lint, build và test (các script do hfs sync quản lý; test chạy bằng npm test)."
1287
1278
  causes_vi:
1288
1279
  - "Quên một hoặc vài lệnh"
1289
1280
  - "Viết bằng lệnh của trình quản lý khác"
@@ -1413,7 +1404,7 @@ HFS_ROOT_MARKDOWN_FORBIDDEN:
1413
1404
  kind: check-finding
1414
1405
 
1415
1406
  HFS_ROOT_SRC_FORBIDDEN_FE:
1416
- title: "Root src folder in frontend repo"
1407
+ title: "Root src folder in the front end (fe/)"
1417
1408
  title_vi: "Repo giao diện có thư mục src ở gốc"
1418
1409
  meaning_vi: "Repo giao diện có thư mục src ngay ở gốc, trong khi mã nguồn chỉ được đặt trong apps/<tên app>/src."
1419
1410
  causes_vi:
@@ -1433,18 +1424,6 @@ HFS_RULE_OFF_WITHOUT_REPLACEMENT:
1433
1424
  owner: op-retry
1434
1425
  kind: check-finding
1435
1426
 
1436
- HFS_SIZE_GROWTH:
1437
- title: "Source file over the size budget grew"
1438
- title_vi: "Tệp mã nguồn vượt hạn mức dòng vẫn tiếp tục dài thêm"
1439
- meaning_vi: "Một tệp mã nguồn đã dài hơn hạn mức mềm của ruleParams.fileLines nhưng lại dài thêm so với phiên bản gốc của nhánh, hoặc một tệp mới tạo đã vượt hạn mức ngay từ đầu. Tệp quá dài khó đọc, khó sửa và là nơi lỗi dễ ẩn; quy tắc này cho phép tệp cũ đang quá dài được giữ nguyên hoặc thu nhỏ dần, nhưng không cho phép nó phình thêm."
1440
- causes_vi:
1441
- - "Op thêm mã mới vào một tệp vốn đã quá hạn mức thay vì tách phần mới sang tệp khác"
1442
- - "Một tệp đang dưới hạn mức được thêm mã cho đến khi vượt hạn mức"
1443
- - "Một tệp mới tạo dài hơn hạn mức, hoặc một tệp bị di chuyển sang đường dẫn mới nên bị coi là tệp mới"
1444
- nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để tách phần mã mới (hoặc bớt phần cũ) sang tệp riêng đúng ô của nó, sao cho tệp không dài hơn phiên bản gốc và tệp mới nằm trong hạn mức."
1445
- owner: op-retry
1446
- kind: check-finding
1447
-
1448
1427
  HFS_SIZE_SOFT_BACKLOG:
1449
1428
  title: "Source file above the soft size"
1450
1429
  title_vi: "Tệp mã vượt cỡ mềm (chỉ báo cáo)"
@@ -1478,12 +1457,12 @@ HFS_SLOT_NOT_ENABLED:
1478
1457
  kind: check-finding
1479
1458
 
1480
1459
  HFS_SLOT_REQUIRED_MISSING:
1481
- title: "A slot is missing a required file (feature `index.ts`, app composition spec, FE `global-error.tsx`)"
1460
+ title: "A slot is missing a required file (feature `index.ts`, app `main.ts`, FE `global-error.tsx`)"
1482
1461
  title_vi: "Ô thiếu tệp bắt buộc"
1483
1462
  meaning_vi: "Một ô bắt buộc (hoặc một thực thể của ô, như một ứng dụng hay một tính năng) đòi có tệp hoặc thư mục này nhưng nó không nằm trong những gì Git theo dõi."
1484
1463
  causes_vi:
1485
- - "Vi phạm luật R02: slot bắt buộc thiếu tệp `requires` (vd feature thiếu `index.ts`, app thiếu composition spec, FE thiếu `global-error.tsx`)."
1486
- - "Ứng dụng thiếu main.ts, app.module.ts hoặc spec ghép ứng dụng"
1464
+ - "Vi phạm luật R02: slot bắt buộc thiếu tệp `requires` (vd feature thiếu `index.ts`, app thiếu `main.ts`, FE thiếu `global-error.tsx`)."
1465
+ - "Ứng dụng thiếu main.ts hoặc app.module.ts"
1487
1466
  - "Kho thiếu tệp gốc bắt buộc như README.md, hfs.json, .starciwork, .starcistacks"
1488
1467
  nextStep_vi: "Tạo tệp hoặc thư mục theo mẫu của ô rồi commit; tệp chưa git add vẫn tính là thiếu. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra; không cần ai can thiệp thêm."
1489
1468
  owner: op-retry
@@ -1525,7 +1504,7 @@ HFS_SRC_LAYOUT_INVALID:
1525
1504
  kind: check-finding
1526
1505
 
1527
1506
  HFS_STACKS_IN_FE:
1528
- title: "Stack declarations in frontend repo"
1507
+ title: "Stack declarations in the front end (fe/)"
1529
1508
  title_vi: "Khai báo stack nằm trong repo giao diện"
1530
1509
  meaning_vi: "Repo giao diện đang chứa thư mục .starcistacks, trong khi khai báo stack chỉ được đặt ở repo backend."
1531
1510
  causes_vi:
@@ -1622,7 +1601,7 @@ HFS_UNUSED_FILE:
1622
1601
  kind: check-finding
1623
1602
 
1624
1603
  HFS_WORK_IN_FE:
1625
- title: "Work tree inside frontend repo"
1604
+ title: "Work tree inside the front end (fe/)"
1626
1605
  title_vi: "Hồ sơ Work nằm trong repo giao diện"
1627
1606
  meaning_vi: "Repo giao diện chứa thư mục .starciwork, trong khi hồ sơ Work chỉ được lưu ở repo backend."
1628
1607
  causes_vi:
@@ -227,7 +227,7 @@ function externalTransportReexport(ts, sourceFile, selected) {
227
227
  for (const statement of sourceFile.statements) {
228
228
  if (!ts.isExportDeclaration(statement)) continue;
229
229
  const clause = statement.exportClause;
230
- const specifier = ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
230
+ const specifier = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
231
231
  if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) {
232
232
  if (!clause) {
233
233
  if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
@@ -61,7 +61,12 @@ function existingRegularFile(root, relative) {
61
61
  }
62
62
  }
63
63
 
64
- function workspaceDirectories(root) {
64
+ /**
65
+ * The npm workspaces below `root`. The one package.json of an app is at `packageRoot` (the app root; `root` is its side folder):
66
+ * its workspace patterns under `<side>/` are this side's, read relative to the side folder, and every other pattern is the other
67
+ * side's.
68
+ */
69
+ function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side = null } = {}) {
65
70
  const directories = new Set();
66
71
  const queue = [root];
67
72
  const visited = new Set();
@@ -89,11 +94,14 @@ function workspaceDirectories(root) {
89
94
  const packageRoot = queue.shift();
90
95
  if (visited.has(packageRoot)) continue;
91
96
  visited.add(packageRoot);
92
- const pkg = readJson(path.join(packageRoot, 'package.json'));
97
+ const sideRoot = side !== null && packageRoot === root;
98
+ const pkg = readJson(path.join(sideRoot ? appPackageRoot : packageRoot, 'package.json'));
93
99
  const patterns = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
94
100
  for (const pattern of Array.isArray(patterns) ? patterns : []) {
95
101
  if (typeof pattern !== 'string' || !pattern.trim()) throw Error('package.json workspace entries must be non-empty paths.');
96
- const normalized = slash(pattern.trim()).replace(/^\.\//, '');
102
+ const written = slash(pattern.trim()).replace(/^\.\//, '');
103
+ if (sideRoot && !written.startsWith(`${side}/`)) continue;
104
+ const normalized = sideRoot ? written.slice(side.length + 1) : written;
97
105
  const segments = normalized.split('/');
98
106
  if (path.isAbsolute(normalized) || segments.some(segment => segment === '..' || (segment.includes('*') && segment !== '*'))) {
99
107
  throw Error(`Unsupported local workspace pattern: ${normalized}.`);
@@ -121,7 +129,10 @@ function workspaceDirectories(root) {
121
129
  for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
122
130
  for (const [name, value] of Object.entries(pkg?.[section] ?? {})) {
123
131
  if (typeof value !== 'string' || !value.startsWith('file:')) continue;
124
- const absolute = path.resolve(packageRoot, value.slice('file:'.length));
132
+ const absolute = path.resolve(sideRoot ? appPackageRoot : packageRoot, value.slice('file:'.length));
133
+ // The app's one package.json also lists the other side's file dependencies (inside the app, outside this side): not this
134
+ // side's. A path that leaves the app is judged like any other: a package of the same repository, or refused.
135
+ if (sideRoot && !isInside(root, absolute) && isInside(appPackageRoot, absolute)) continue;
125
136
  const label = `${section}.${name} file dependency`;
126
137
  if (isInside(root, absolute)) { admit(absolute, label, true); continue; }
127
138
  // A sibling package of the same repository: this project consumes it, so the path is real and
@@ -248,15 +259,19 @@ const GRAMMAR_PACKAGE = '@starci/grammar';
248
259
  * source, every app and every workspace package that depends on the Grammar package is a consumer. null when no app has
249
260
  * a globals.css to judge (the contract is then reported unavailable, never passed).
250
261
  */
251
- function derivedGrammar(root, apps, workspaces) {
262
+ function derivedGrammar(root, packageRoot, workspaces, apps = []) {
252
263
  const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
253
264
  if (!styleSources.length) return null;
254
- const consumerManifests = apps.map(app => `apps/${app.name}/package.json`).filter(relative => existingRegularFile(root, relative));
265
+ // The apps have no package.json of their own: the app root's one manifest declares their dependencies, and it is a consumer
266
+ // when it declares the Grammar package; so is every workspace package that does.
267
+ const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE)));
268
+ const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
269
+ const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
255
270
  for (const workspace of workspaces) {
256
271
  if (!workspace.startsWith('packages/')) continue;
257
272
  const manifest = `${workspace}/package.json`;
258
273
  const pkg = readJson(path.join(root, ...manifest.split('/')));
259
- if (pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE))) consumerManifests.push(manifest);
274
+ if (declaresGrammar(pkg)) consumerManifests.push(manifest);
260
275
  }
261
276
  if (!consumerManifests.length) return null;
262
277
  return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react'] };
@@ -272,10 +287,14 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
272
287
  if (!fs.lstatSync(root).isDirectory()) throw Error('Repository root must be a directory.');
273
288
  const opened = hfs ?? openHfs({ repoRoot: root });
274
289
  const { profile, apps } = opened.repo;
275
- const workspaces = workspaceDirectories(root);
276
- const inferred = inferredLayout(root, [...new Set([...workspaces, ...apps.map(app => `apps/${app.name}`)])].sort());
290
+ // A side of an app (the side folder is the root the machine judges) keeps its dependencies in the app root's one package.json.
291
+ const side = opened.repo.side ?? null;
292
+ const packageRoot = side === null ? root : path.dirname(root);
293
+ const workspaces = workspaceDirectories(root, { packageRoot, side });
294
+ const appDirs = apps.map(app => `apps/${app.name}`);
295
+ const inferred = inferredLayout(root, [...new Set([...workspaces, ...appDirs])].sort());
277
296
  const kinds = [profile === 'be' ? 'backend' : 'frontend'];
278
- const projects = discoveredProjects(root, workspaces);
297
+ const projects = discoveredProjects(root, [...new Set([...workspaces, ...appDirs])].sort());
279
298
  if (!projects.length) throw Error('The repository has no tsconfig.json to derive a TypeScript project from.');
280
299
  const backend = {
281
300
  modules: ['src/modules'],
@@ -290,11 +309,12 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
290
309
  hooks: inferred.hooks,
291
310
  modules: inferred.modules,
292
311
  transport: inferred.transport,
293
- grammar: profile === 'fe' ? derivedGrammar(root, apps, workspaces) : null,
312
+ grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps) : null,
294
313
  };
295
314
  if (profile === 'fe') assertFrontendRolesDisjoint(root, frontend);
296
315
  return {
297
316
  root,
317
+ packageRoot,
298
318
  repository: enclosingRepository(root),
299
319
  kinds,
300
320
  projects,
@@ -276,7 +276,7 @@ function contractTypeStatusFromType(ts, checker, type, seen = new Set(), depth =
276
276
  }
277
277
  for (const anonymous of ['Record', 'Partial', 'Pick', 'Omit', 'Required']) if (builtinSymbol(ts, named, anonymous)) return 'inline';
278
278
  if (type.aliasSymbol) return 'named';
279
- if (type.symbol?.getDeclarations?.().some(declaration => ts.isEnumDeclaration(declaration))) return 'named';
279
+ if ((type.symbol?.getDeclarations?.() ?? []).some(declaration => ts.isEnumDeclaration(declaration))) return 'named';
280
280
  if (type.flags & ts.TypeFlags.TypeParameter) return 'named';
281
281
  if (type.flags & ts.TypeFlags.Boolean) return 'scalar';
282
282
  if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) return 'inline';
@@ -590,7 +590,7 @@ function checkMessageReadonly(config, context, checker, declaration, localFiles,
590
590
  }
591
591
  const type = classInstanceType(ts, checker, selected);
592
592
  for (const base of type && checker.getBaseTypes ? checker.getBaseTypes(type) : []) {
593
- const declarations = base.symbol?.getDeclarations?.().filter(item => ts.isClassDeclaration(item)) ?? [];
593
+ const declarations = (base.symbol?.getDeclarations?.() ?? []).filter(item => ts.isClassDeclaration(item)) ?? [];
594
594
  if (declarations.length !== 1) {
595
595
  reasons.push(`${relativePath(config.root, sourceFile.fileName)} inherits message fields from a class outside the checked production program`);
596
596
  } else if (localFiles.has(canonical(declarations[0].getSourceFile().fileName))) visitClass(declarations[0]);
@@ -654,7 +654,7 @@ function checkInjectedClass(config, context, checker, declaration, classKind, ta
654
654
  if (classKind) {
655
655
  const type = classInstanceType(ts, checker, declaration);
656
656
  for (const base of type && checker.getBaseTypes ? checker.getBaseTypes(type) : []) {
657
- const declarations = base.symbol?.getDeclarations?.().filter(item => ts.isClassDeclaration(item)) ?? [];
657
+ const declarations = (base.symbol?.getDeclarations?.() ?? []).filter(item => ts.isClassDeclaration(item)) ?? [];
658
658
  if (declarations.length !== 1) {
659
659
  reasons.push(`${relativePath(config.root, sourceFile.fileName)} inherits an injected constructor from a class outside the checked production program`);
660
660
  } else if (localFiles.has(canonical(declarations[0].getSourceFile().fileName))) {
@@ -709,7 +709,7 @@ export function checkBackendContracts(config, context) {
709
709
  publicReasons.push(`${owner.entry} uses export =, so named capability API discovery is unavailable`);
710
710
  }
711
711
  for (const symbol of exportedSymbols(ts, checker, entry)) {
712
- const declarations = symbol.getDeclarations?.().filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName))) ?? [];
712
+ const declarations = (symbol.getDeclarations?.() ?? []).filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName))) ?? [];
713
713
  if (!declarations.length) continue;
714
714
  const representative = declarations[0];
715
715
  if (isFrameworkHelper(representative.getSourceFile(), representative, framework)) continue;
@@ -1,14 +1,17 @@
1
1
  import { treeOf } from './required-files.mjs';
2
2
  import { allowsFile } from '../../lib/hfs-allows.mjs';
3
+ import { isFeTestPath } from '../../lib/hfs-rules/fe-no-tests.mjs';
3
4
 
4
5
  /**
5
6
  * R94 `fe-slot-allows` (FE_SLOT_FILE_ROLE). A front-end slot that owns a whole directory (`fe.route`, `fe.feature`,
6
7
  * `fe.components`, `fe.hooks`, `fe.modules.api`) also says what its instances hold (`allows` in knowledge/hfs/slots.yaml).
7
8
  * Every tracked file of such a slot is matched against the `requires` and `allows` entries of its slot; a file no entry names is a
8
- * finding - `page.tsx` in a hooks domain, a `[lang]` segment under `app/`, a `.test.tsx` beside a component, a stray file at
9
- * a feature root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
9
+ * finding - `page.tsx` in a hooks domain, a `[lang]` segment under `app/`, a stray file beside a component or at a feature
10
+ * root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
10
11
  * Files no slot owns are HFS_PATH_NO_SLOT's (`hfs check`), so together every front-end file is placed by exactly one slot and
11
- * named by it.
12
+ * named by it. A test path (a `.spec.tsx` or `.test.tsx` beside a component, a test directory or test tooling) is FE_NO_TESTS's
13
+ * (R97, contract-change fe-no-tests): that rule is the one finding of such a file, so this check leaves it alone, as
14
+ * HFS_PATH_NO_SLOT does (scripts/lib/hfs-path-findings.mjs).
12
15
  */
13
16
  export const FE_SLOT_ALLOWS_RULE_IDS = ['FE_SLOT_FILE_ROLE'];
14
17
 
@@ -21,6 +24,7 @@ export function checkFeSlotAllows({ config, graph }) {
21
24
  let files = 0;
22
25
  const report = (file, message, extra = {}) => violations.push({ ruleId: RULE, path: file, line: 1, column: 1, message, ...extra });
23
26
  for (const file of [...tree.files].sort()) {
27
+ if (isFeTestPath(file)) continue; // FE_NO_TESTS's, the one finding of that file
24
28
  const classified = resolver.classifyPath(file);
25
29
  if (classified.status !== 'owned') continue;
26
30
  const slot = resolver.slot(classified.slot);
@@ -1,14 +1,11 @@
1
1
  // framework-pinned.mjs - the Next.js files that only load from a source root (the directory holding
2
- // app/), and the export names the framework mandates in them. Authored once in
3
- // knowledge/patterns/fe/folder.yaml FE-FOLDER-1 (frameworkPinnedRootFiles, frameworkPinnedRootExports),
4
- // never hard-coded here (supervisor rulings, nivo wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4 and
5
- // inc-846867b9a34e). Read by the architecture check (FE_SOURCE_LAYOUT_INVALID) and the Next name-shape
6
- // check (FE_SOURCE_NAME_SHAPE).
2
+ // app/). Authored once in knowledge/patterns/fe/folder.yaml FE-FOLDER-1 (frameworkPinnedRootFiles), never
3
+ // hard-coded here (supervisor ruling, nivo wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4). Read by the architecture
4
+ // check (FE_SOURCE_LAYOUT_INVALID).
7
5
  //
8
- // An unreadable or malformed list is a broken install, not "no pinned files": both readers throw
6
+ // An unreadable or malformed list is a broken install, not "no pinned files": the reader throws
9
7
  // ARCH_KNOWLEDGE_UNAVAILABLE so the caller reports an error instead of judging with a different contract.
10
8
  import fs from 'node:fs';
11
- import path from 'node:path';
12
9
  import { fileURLToPath } from 'node:url';
13
10
  import { parseYaml } from '../../../engine/yaml.mjs';
14
11
 
@@ -21,11 +18,6 @@ function unavailable(file, detail) {
21
18
  return Error(`ARCH_KNOWLEDGE_UNAVAILABLE: ${file} ${detail}`);
22
19
  }
23
20
 
24
- /** The file stem Next.js keys a pinned file by: `middleware.ts` -> `middleware`, `next-env.d.ts` -> `next-env`. */
25
- export function pinnedStem(fileName) {
26
- return path.basename(fileName).replace(/(?:\.d)?\.[cm]?[jt]sx?$/i, '');
27
- }
28
-
29
21
  function load(file) {
30
22
  if (cache.has(file)) return cache.get(file);
31
23
  let rule;
@@ -38,21 +30,7 @@ function load(file) {
38
30
  if (!Array.isArray(list) || !list.length || list.some(name => typeof name !== 'string' || !EXACT_NAME.test(name))) {
39
31
  throw unavailable(file, 'FE-FOLDER-1 frameworkPinnedRootFiles must be a nonempty list of exact file names.');
40
32
  }
41
- const files = new Set(list);
42
- const stems = new Set(list.map(pinnedStem));
43
- const declared = rule.frameworkPinnedRootExports;
44
- if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
45
- throw unavailable(file, 'FE-FOLDER-1 frameworkPinnedRootExports must map a pinned file stem to its framework-mandated export names.');
46
- }
47
- const exports = new Map();
48
- for (const [stem, names] of Object.entries(declared)) {
49
- if (!stems.has(stem)) throw unavailable(file, `FE-FOLDER-1 frameworkPinnedRootExports names ${stem}, which no frameworkPinnedRootFiles entry pins.`);
50
- if (!Array.isArray(names) || !names.length || names.some(name => typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name))) {
51
- throw unavailable(file, `FE-FOLDER-1 frameworkPinnedRootExports.${stem} must be a nonempty list of export identifiers.`);
52
- }
53
- exports.set(stem, new Set(names));
54
- }
55
- const loaded = { files, exports };
33
+ const loaded = { files: new Set(list) };
56
34
  if (file === FRAMEWORK_PINNED_KNOWLEDGE) cache.set(file, loaded);
57
35
  return loaded;
58
36
  }
@@ -61,23 +39,3 @@ function load(file) {
61
39
  export function frameworkPinnedRootFiles(file = FRAMEWORK_PINNED_KNOWLEDGE) {
62
40
  return load(file).files;
63
41
  }
64
-
65
- /** Map of pinned file stem to the export names the framework mandates there. */
66
- export function frameworkPinnedRootExports(file = FRAMEWORK_PINNED_KNOWLEDGE) {
67
- return load(file).exports;
68
- }
69
-
70
- /**
71
- * The framework-mandated export names for an absolute source file when it is a pinned file sitting
72
- * directly in a Next source root (its directory holds an app/ directory), else null.
73
- */
74
- export function frameworkMandatedExports(absoluteFile, file = FRAMEWORK_PINNED_KNOWLEDGE) {
75
- const { files, exports } = load(file);
76
- if (!files.has(path.basename(absoluteFile))) return null;
77
- try {
78
- if (!fs.statSync(path.join(path.dirname(absoluteFile), 'app')).isDirectory()) return null;
79
- } catch {
80
- return null;
81
- }
82
- return exports.get(pinnedStem(absoluteFile)) ?? new Set();
83
- }
@@ -108,13 +108,15 @@ function checkGrammar(config, context) {
108
108
  // Node resolution per consumer: in an npm-workspaces monorepo a consumer whose range differs from the
109
109
  // hoisted copy gets its own apps/<app>/node_modules/<package> (nivo-fe: apps/app on 0.5.0 beside a hoisted
110
110
  // 0.4.11). Every consumer is judged against the copy it actually resolves, never the hoisted one alone.
111
+ // An app installs once, at its root (config.packageRoot): a side folder has no node_modules of its own.
112
+ const installRoot = config.packageRoot ?? config.root;
111
113
  const installedFrom = (from) => {
112
- for (let dir = from; isInside(config.root, dir); dir = path.dirname(dir)) {
114
+ for (let dir = from; isInside(installRoot, dir); dir = path.dirname(dir)) {
113
115
  const candidate = path.join(dir, 'node_modules', ...grammar.package.split('/'));
114
116
  if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
115
- if (dir === config.root || path.dirname(dir) === dir) break;
117
+ if (dir === installRoot || path.dirname(dir) === dir) break;
116
118
  }
117
- return path.join(config.root, 'node_modules', ...grammar.package.split('/'));
119
+ return path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
118
120
  };
119
121
  const consumers = grammar.consumerManifests.map(relative => {
120
122
  const root = path.dirname(path.join(config.root, ...relative.split('/')));
@@ -123,7 +125,7 @@ function checkGrammar(config, context) {
123
125
  manifest: readJson(path.join(config.root, ...relative.split('/'))) ?? {} };
124
126
  });
125
127
  const installs = [...new Map(consumers.map(consumer => [consumer.packageRoot, consumer])).values()];
126
- const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(config.root, 'node_modules', ...grammar.package.split('/'));
128
+ const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
127
129
  const manifestFile = path.join(packageRoot, 'package.json');
128
130
  const manifest = readJson(manifestFile);
129
131
  const ownerOf = (fileName) => consumers.filter(consumer => isInside(consumer.root, fileName))
@@ -5,7 +5,7 @@ import { relativePath } from './typescript.mjs';
5
5
  * The file and owner graph the HFS architecture checks share. Every production source file the TypeScript context
6
6
  * loaded becomes a node classified by the slot manifest (config.hfs is the resolver of scripts/lib/hfs-slots.mjs);
7
7
  * every import, re-export and type-only import between two of them becomes an edge. Nothing here judges: the checks
8
- * in tiers.mjs, reachability.mjs, dead-exports.mjs, required-files.mjs, size-growth.mjs and clones.mjs read it.
8
+ * in tiers.mjs, reachability.mjs, dead-exports.mjs, required-files.mjs and clones.mjs read it.
9
9
  *
10
10
  * graph.profile 'be' | 'fe'
11
11
  * graph.resolver the slot resolver (config.hfs)