@starci/hfs 3.0.0 → 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 (198) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +25 -21
  3. package/bin/hfs.mjs +59 -37
  4. package/lint/run.mjs +70 -39
  5. package/package.json +2 -2
  6. package/runtime/engine/admission.mjs +3 -3
  7. package/runtime/engine/ledger-db.mjs +2 -2
  8. package/runtime/engine/machine-db.mjs +90 -9
  9. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  10. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  12. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  13. package/runtime/knowledge/hfs/slots.yaml +193 -128
  14. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  15. package/runtime/modules/kernel/failure-codes.yaml +23 -32
  16. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  17. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  18. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  19. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  20. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  21. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  22. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  23. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  24. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  26. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  27. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  28. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  29. package/runtime/scripts/lib/hfs-check.mjs +156 -141
  30. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  31. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  32. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  33. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  34. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  35. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  36. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  37. package/runtime/scripts/lib/language.mjs +11 -1
  38. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  39. package/scaffold/app.mjs +179 -0
  40. package/scaffold/service.mjs +26 -16
  41. package/sync/cli.mjs +1 -1
  42. package/sync/hygiene.mjs +11 -8
  43. package/sync/index.mjs +109 -111
  44. package/sync/managed.mjs +9 -8
  45. package/sync/sonar-key.mjs +20 -22
  46. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
  47. package/templates/app/gitignore +6 -0
  48. package/templates/app/hooks/husky/pre-commit +25 -0
  49. package/templates/app/hooks/husky/pre-push +7 -0
  50. package/templates/app/package-scripts/package.json +22 -0
  51. package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
  52. package/templates/app/skeleton/.editorconfig +15 -0
  53. package/templates/app/skeleton/.gitattributes +2 -0
  54. package/templates/app/skeleton/.nvmrc +1 -0
  55. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  56. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  57. package/templates/app/skeleton/README.md +36 -0
  58. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  59. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  60. package/templates/be/skeleton/.sops.yaml +2 -0
  61. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  62. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  63. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  64. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  65. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  66. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  67. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  68. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  69. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  70. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  71. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  72. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  73. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  76. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  77. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  78. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  79. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  80. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  81. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  82. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  83. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  84. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  85. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  86. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  87. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  88. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  89. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  90. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  91. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  95. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  96. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  97. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  98. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  99. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  100. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  101. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  102. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  103. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  107. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  108. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  109. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  110. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  111. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  112. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  113. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  114. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  118. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  119. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  120. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  121. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  122. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  123. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  124. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  125. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  126. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  127. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  128. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  129. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  130. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  131. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  132. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  133. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  134. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  135. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  136. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  137. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  138. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  139. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  140. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  141. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  142. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  143. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  147. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  148. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  149. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  150. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  151. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  152. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  153. package/sync/skeleton.mjs +0 -76
  154. package/templates/be/gitignore +0 -2
  155. package/templates/be/hooks/husky/pre-commit +0 -13
  156. package/templates/be/hooks/husky/pre-push +0 -6
  157. package/templates/be/package-scripts/package.json +0 -19
  158. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  159. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  160. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  161. package/templates/be/tool-config/prettierignore +0 -8
  162. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
  163. package/templates/fe/gitignore +0 -3
  164. package/templates/fe/hooks/husky/pre-commit +0 -16
  165. package/templates/fe/hooks/husky/pre-push +0 -5
  166. package/templates/fe/package-scripts/package.json +0 -13
  167. package/templates/fe/parts/api-client.ts +0 -44
  168. package/templates/fe/parts/api-outcome.ts +0 -7
  169. package/templates/fe/quality-config/sonar-project.properties +0 -8
  170. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  171. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  172. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  173. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  174. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  175. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  176. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  177. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  178. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  179. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  180. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  181. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  182. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  183. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  184. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  185. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  186. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  187. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  188. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  189. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  190. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  191. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  192. package/templates/fe/tool-config/prettierrc +0 -1
  193. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  194. /package/templates/{be → app}/starciwork.gitignore +0 -0
  195. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  196. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  197. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  198. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -444,6 +444,7 @@ BE_RAW_INJECT:
444
444
  - "Token là chuỗi thay vì unique symbol"
445
445
  - "Tiêm lớp hạ tầng (CacheService, AppLogger) không qua injector"
446
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"
447
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."
448
449
  owner: op-retry
449
450
  kind: check-finding
@@ -483,8 +484,9 @@ BE_SCHEMA_OWNER:
483
484
  BE_SOURCE_FORM:
484
485
  title: "Back-end source file breaks the naming or form canon"
485
486
  title_vi: "Tệp nguồn BE sai quy ước tên hoặc hình thức"
486
- 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)."
487
488
  causes_vi:
489
+ - "class MockExpertAgentService trong mã production thay vì adapter điều khiển bằng options"
488
490
  - "Tệp dùng hậu tố bị cấm (use-case, repository, store, util, helper, types, constants)"
489
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"
490
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"
@@ -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
@@ -991,11 +993,11 @@ HFS_CI_MISSING_CANON:
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"
@@ -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:
@@ -1513,7 +1504,7 @@ HFS_SRC_LAYOUT_INVALID:
1513
1504
  kind: check-finding
1514
1505
 
1515
1506
  HFS_STACKS_IN_FE:
1516
- title: "Stack declarations in frontend repo"
1507
+ title: "Stack declarations in the front end (fe/)"
1517
1508
  title_vi: "Khai báo stack nằm trong repo giao diện"
1518
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."
1519
1510
  causes_vi:
@@ -1610,7 +1601,7 @@ HFS_UNUSED_FILE:
1610
1601
  kind: check-finding
1611
1602
 
1612
1603
  HFS_WORK_IN_FE:
1613
- title: "Work tree inside frontend repo"
1604
+ title: "Work tree inside the front end (fe/)"
1614
1605
  title_vi: "Hồ sơ Work nằm trong repo giao diện"
1615
1606
  meaning_vi: "Repo giao diện chứa thư mục .starciwork, trong khi hồ sơ Work chỉ được lưu ở repo backend."
1616
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))