@starci/hfs 4.2.12 → 4.2.14

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 (203) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +7 -3
  3. package/emit/db-types.mjs +2 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
  6. package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
  7. package/runtime/knowledge/hfs/rules.yaml +51 -48
  8. package/runtime/knowledge/hfs/slots.yaml +319 -179
  9. package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
  10. package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
  11. package/runtime/knowledge/patterns/be/projections.yaml +10 -0
  12. package/runtime/knowledge/patterns/be/queues.yaml +15 -0
  13. package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
  14. package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
  15. package/runtime/knowledge/patterns/be/saga.yaml +13 -0
  16. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  17. package/runtime/knowledge/sonar-gate.yaml +10 -2
  18. package/runtime/modules/kernel/failure-codes.yaml +40 -60
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
  20. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  21. package/runtime/scripts/hfs/architecture/config.mjs +3 -2
  22. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  23. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  24. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  25. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  26. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  27. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  28. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  29. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  30. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  31. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  32. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  33. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  34. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  35. package/runtime/scripts/hfs/check.mjs +10 -6
  36. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  37. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  38. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  39. package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
  40. package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
  41. package/runtime/scripts/hfs/rules/database.mjs +4 -3
  42. package/runtime/scripts/hfs/rules/edition.mjs +49 -37
  43. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  44. package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
  45. package/scaffold/add-app.mjs +24 -18
  46. package/scaffold/add-cli-lite.mjs +33 -3
  47. package/scaffold/add-inbox-lite.mjs +24 -8
  48. package/scaffold/add-table.mjs +222 -74
  49. package/scaffold/add.mjs +85 -35
  50. package/scaffold/app.mjs +146 -38
  51. package/scaffold/edition-gate.mjs +21 -2
  52. package/scaffold/lite-exports.mjs +30 -1
  53. package/scaffold/service.mjs +5 -8
  54. package/src/main.mjs +2 -2
  55. package/sync/index.mjs +19 -15
  56. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
  57. package/templates/app/codegen-graphql.mjs.partial +34 -0
  58. package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
  59. package/templates/app/package-scripts/core.json +1 -0
  60. package/templates/app/package-scripts-lite/package.json +4 -0
  61. package/templates/app/skeleton/README.md +1 -1
  62. package/templates/app/skeleton/scripts/codegen.mjs +4 -1
  63. package/templates/app/skeleton-lite/README.md +7 -4
  64. package/templates/app/skeleton-lite/supabase/config.toml +1 -1
  65. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
  66. package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
  67. package/templates/be/inbox/service.spec.ts.tpl +46 -0
  68. package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
  69. package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
  70. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
  71. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
  72. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
  73. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
  74. package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
  75. package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
  76. package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
  77. package/templates/be/platform/graphql/graphql.module.ts +49 -0
  78. package/templates/be/platform/graphql/graphql.options.ts +2 -0
  79. package/templates/be/platform/graphql/index.ts +1 -0
  80. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
  81. package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
  82. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
  83. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
  84. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
  85. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
  86. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
  87. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
  88. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
  89. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
  90. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
  91. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
  92. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
  93. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  94. package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
  95. package/templates/be/table/api/create.command.ts.tpl +10 -0
  96. package/templates/be/table/api/create.contracts.ts.tpl +8 -0
  97. package/templates/be/table/api/create.handler.ts.tpl +22 -0
  98. package/templates/be/table/api/create.mapper.ts.tpl +5 -0
  99. package/templates/be/table/api/create.resolver.ts.tpl +21 -0
  100. package/templates/be/table/api/create.type.ts.tpl +9 -0
  101. package/templates/be/table/api/feature.index.ts.tpl +1 -0
  102. package/templates/be/table/api/feature.module.ts.tpl +7 -0
  103. package/templates/be/table/api/get.contracts.ts.tpl +11 -0
  104. package/templates/be/table/api/get.handler.ts.tpl +22 -0
  105. package/templates/be/table/api/get.input.ts.tpl +12 -0
  106. package/templates/be/table/api/get.mapper.ts.tpl +9 -0
  107. package/templates/be/table/api/get.query.ts.tpl +10 -0
  108. package/templates/be/table/api/get.resolver.ts.tpl +27 -0
  109. package/templates/be/table/api/get.type.ts.tpl +9 -0
  110. package/templates/be/table/api/graphql.module.ts.tpl +8 -0
  111. package/templates/be/table/error.api.ts.tpl +19 -0
  112. package/templates/be/table/migration.sql.tpl +13 -21
  113. package/templates/be/table/service.api.ts.tpl +30 -0
  114. package/templates/be/table/sql.api.ts.tpl +7 -0
  115. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
  116. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
  117. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
  118. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
  119. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
  120. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
  121. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
  122. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
  123. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
  124. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
  125. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
  126. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
  127. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
  128. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
  129. package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
  130. package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
  131. package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
  132. package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
  133. package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
  134. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
  135. package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
  136. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +27 -0
  137. package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
  138. package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
  139. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
  140. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
  141. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
  142. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
  143. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
  144. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
  145. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
  146. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
  147. package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
  148. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
  149. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
  150. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
  151. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
  152. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
  153. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
  154. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
  155. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
  156. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
  157. package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
  158. package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
  159. package/templates/fe/table/graphql/query.ts.tpl +25 -0
  160. package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
  161. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
  162. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
  163. package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
  164. package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
  165. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
  166. package/templates/fe/transport/graphql/clients/index.ts +2 -0
  167. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
  168. package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
  169. package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
  170. package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
  171. package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
  172. package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
  173. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
  174. package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
  175. package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
  176. package/templates/fe/transport/graphql/clients/response.ts +43 -0
  177. package/upgrade/check.mjs +1 -1
  178. package/upgrade/index.mjs +3 -2
  179. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
  180. package/templates/app/quality-config-lite/sonar-project.properties +0 -5
  181. package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
  182. package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
  183. package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
  184. package/templates/be/patterns/api/http.module.ts.tpl +0 -7
  185. package/templates/be/patterns/api/http.request.ts.tpl +0 -10
  186. package/templates/be/patterns/api/http.response.ts.tpl +0 -5
  187. package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
  188. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
  189. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
  192. package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
  193. package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
  194. package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
  195. package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
  196. package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
  197. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
  198. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
  199. package/templates/fe/table/read.ts.tpl +0 -16
  200. package/templates/fe/table/write.ts.tpl +0 -16
  201. /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
  202. /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
  203. /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
@@ -2,36 +2,44 @@ import path from 'node:path';
2
2
  import { machineKit } from './machine-ast.mjs';
3
3
 
4
4
  /**
5
- * R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint rules `fetch-only-in-api-client` and
6
- * `client-fetch-has-signal`. ESLint judges one file at a time by the spelling of a call; the machine judges the repository by
7
- * what the checker resolves. A repository has exactly ONE transport client and ONE Outcome union, both named by slot (never
8
- * by a path spelled here): the api package's (`fe.package.api.client`, `fe.package.api.outcome`) when the repository shares
9
- * it, or the only app's (`fe.transport.client`, `fe.transport.outcome`) when the repository declares exactly one app. The
10
- * Outcome homes are the slots the manifest marks `outcomeHome` (the lite edition adds `fe.modules.db.outcome`), not a list kept here.
5
+ * R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint transport rules. ESLint judges one file at a time by
6
+ * the spelling of a call; the machine judges the repository by what the checker resolves. The browser calls the back end through
7
+ * Apollo GraphQL and axios (owner ruling 2026-10-13, both editions). A repository has exactly ONE transport client folder (the auth
8
+ * and no-auth Apollo clients with their link chain) and ONE GraphQLResponse union, both named by slot (never by a path spelled here):
9
+ * the api package's (`fe.package.api.client`, `fe.package.api.outcome`) when several apps call the back end, or the app's own
10
+ * (`fe.transport.client`, `fe.transport.outcome`) when exactly one app does. An app calls the back end when it holds a file of the
11
+ * api owner (modules/api, its client and union) or imports the api package; an app that holds neither (a marketing landing) shares
12
+ * no transport and is not counted. The union homes are the slots the manifest marks `outcomeHome`, not a list kept here.
11
13
  *
12
- * - two or more clients, or two or more Outcome unions, in the repository (two apps each keeping one, or a package one plus
13
- * an app one) are findings on every copy;
14
- * - an app client or app Outcome union in a repository that declares more than one app is a finding (it belongs to the package);
15
- * - the client calls the global `fetch` (a client that never calls it has its transport somewhere else);
16
- * - no other file references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a reference passed as a value
17
- * (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no client at all every such
18
- * reference is a finding that says the repository has no client;
19
- * - no file imports an HTTP library, by static import, `import()` or `require()`;
20
- * - every server reader (`modules/api/<domain>/read-*.ts`) imports the client (or the api package that holds it), so a reader
21
- * is a caller of the one transport and not a second one.
14
+ * - two or more client folders, or two or more unions, in the repository (two apps each keeping one, or a package one plus an
15
+ * app one) are findings on every copy;
16
+ * - an app client or app union in a repository where more than one app calls the back end is a finding (it belongs to the package);
17
+ * - no file outside the client folder references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
18
+ * reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
19
+ * client at all every such reference is a finding that says the repository has no client;
20
+ * - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
21
+ * second transport wherever it is imported, by static import, `import()` or `require()`;
22
+ * - an `ApolloClient` is constructed only inside the client folder;
23
+ * - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`) imports the client (or the api
24
+ * package that holds it), so an operation is a caller of the one transport and not a second one.
22
25
  */
23
26
  export const TRANSPORT_OWNER_RULE_IDS = ['FE_TRANSPORT_OWNER'];
24
27
 
25
28
  const RULE = 'FE_TRANSPORT_OWNER';
26
- /** Packages that send HTTP requests: a second transport when a file imports one. */
29
+ /** Packages that send HTTP requests: a second transport when a file imports one (axios only outside the api owner). */
27
30
  const HTTP_LIBRARIES = new Set(['axios', 'ky', 'ky-universal', 'got', 'node-fetch', 'undici', 'cross-fetch', 'isomorphic-fetch', 'superagent', 'ofetch', 'whatwg-fetch']);
31
+ const REST_LIBRARY = 'axios';
28
32
  const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'self', 'global']);
29
- const API_SLOT = 'fe.modules.api';
30
33
  const APP_CLIENT_SLOT = 'fe.transport.client';
31
34
  const APP_OUTCOME_SLOT = 'fe.transport.outcome';
32
35
  const PACKAGE_API_SLOT = 'fe.package.api';
33
36
  const CLIENT_SLOTS = new Set([APP_CLIENT_SLOT, 'fe.package.api.client']);
34
- const NO_CLIENT = "the repository has no transport client. Create the one client (packages/<family>-api/src/client.ts, or the only app's modules/api/client.ts) and call it.";
37
+ /** The api owner: the slots whose files may import axios (the rest calls and the client's own links). */
38
+ const API_OWNER_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT, ...CLIENT_SLOTS]);
39
+ /** The slots that hold the GraphQL operations of an app or of the api package. */
40
+ const OPERATION_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT]);
41
+ const OPERATION_FILE = /^(?:query|mutation)-[^/]+\.ts$/u;
42
+ const NO_CLIENT = "the repository has no transport client. Create the one Apollo client folder (packages/<family>-api/src/graphql/clients/, or the only app's modules/api/graphql/clients/) and call the back end through it.";
35
43
 
36
44
  /** True when `node` names the global fetch: no import, and no declaration outside a lib declaration file. */
37
45
  function isGlobalObjectFetch(ts, node) {
@@ -59,12 +67,14 @@ function isGlobalFetch(ts, kit, checker, node) {
59
67
  return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
60
68
  }
61
69
 
62
- function reportMultiplicity(items, noun, nextApps, report, list) {
63
- for (const file of items) {
70
+ /** Findings on every copy when there are several, and on an app's own copy in a repository where several apps call the back end. */
71
+ function reportMultiplicity(items, noun, callingApps, report) {
72
+ const list = items.map(item => item.root).join(', ');
73
+ for (const item of items) {
64
74
  if (items.length > 1) {
65
- report(file, file.sourceFile, `The repository has ${items.length} ${noun}s (${list(items)}); it has exactly one: the api package's (packages/<family>-api/src/) when the apps share it, or the only app's. Keep one and delete the others.`);
66
- } else if ((file.slot === APP_CLIENT_SLOT || file.slot === APP_OUTCOME_SLOT) && nextApps.length > 1) {
67
- report(file, file.sourceFile, `${file.rel} is an app's ${noun} in a repository of ${nextApps.length} apps; a repository with several apps keeps its one ${noun} in the api package (packages/<family>-api/src/).`);
75
+ report(item, item.sourceFile, `The repository has ${items.length} ${noun}s (${list}); it has exactly one: the api package's (packages/<family>-api/src/graphql/) when the apps share it, or the only app's. Keep one and delete the others.`);
76
+ } else if ((item.slot === APP_CLIENT_SLOT || item.slot === APP_OUTCOME_SLOT) && callingApps.length > 1) {
77
+ report(item, item.sourceFile, `${item.root} is an app's ${noun} in a repository of ${callingApps.length} apps that call the back end (${callingApps.join(', ')}); a repository where several apps call the back end keeps its one ${noun} in the api package (packages/<family>-api/src/graphql/).`);
68
78
  }
69
79
  }
70
80
  }
@@ -81,40 +91,80 @@ function moduleSpecifierOf(ts, node) {
81
91
  return null;
82
92
  }
83
93
 
84
- function inspectTransportNode({ ts, kit, checker, file, node, isClient, clientCalls, clients, owned, report }) {
94
+ function isApolloClientConstruction(ts, kit, checker, node) {
95
+ if (!ts.isNewExpression(node)) return false;
96
+ const binding = kit.importBinding(checker, node.expression);
97
+ return Boolean(binding && binding.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
98
+ }
99
+
100
+ function inspectLibraryImport({ ts, file, node, transport, report }) {
101
+ const specifier = moduleSpecifierOf(ts, node);
102
+ if (!specifier || !ts.isStringLiteralLike(specifier)) return;
103
+ const library = specifier.text.split('/')[0];
104
+ if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && API_OWNER_SLOTS.has(file.slot))) return;
105
+ const where = library === REST_LIBRARY
106
+ ? 'axios belongs to the api owner only (modules/api/rest, the api package): call its rest functions'
107
+ : `the transport is Apollo GraphQL (${transport}) with axios for the rest calls of modules/api/rest`;
108
+ report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
109
+ }
110
+
111
+ function inspectTransportNode(scope, node) {
112
+ const { ts, kit, checker, file, isClient, owned, report } = scope;
85
113
  const fetchUse = globalFetchUse(ts, kit, checker, node);
86
114
  if (fetchUse !== null) {
87
- if (isClient) clientCalls.value += 1;
88
- else if (fetchUse === 'property') report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
115
+ if (isClient) return false;
116
+ if (fetchUse === 'property') report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
89
117
  else report(file, node, `fetch is used outside the transport client (called, aliased or passed as a value); ${owned}`);
90
118
  return false;
91
119
  }
92
- const specifier = moduleSpecifierOf(ts, node);
93
- if (specifier && ts.isStringLiteralLike(specifier) && HTTP_LIBRARIES.has(specifier.text.split('/')[0])) {
94
- report(file, node, `${specifier.text} is a second HTTP transport; the repository's one transport is built on fetch (${clients.length ? clients.map(item => item.rel).join(', ') : 'no client exists yet'}). Use the client.`, { library: specifier.text });
95
- }
120
+ if (!isClient && isApolloClientConstruction(ts, kit, checker, node)) report(file, node, `${file.rel} constructs an ApolloClient outside the transport client folder; ${owned}`);
121
+ inspectLibraryImport({ ...scope, node });
96
122
  return true;
97
123
  }
98
124
 
99
- function inspectTransportFiles({ files, graph, kit, ts, clientRels, clients, owned, report }) {
125
+ /** True when the operation imports the client folder, or (from an app) the api package that holds it. */
126
+ function reachesClient(graph, file, clientRels) {
127
+ return graph.edges.some(edge => {
128
+ if (edge.from !== file.rel || !edge.runtime) return false;
129
+ if (clientRels.has(edge.to)) return true;
130
+ return file.slot !== PACKAGE_API_SLOT && (graph.files.get(edge.to)?.slot ?? '').startsWith(PACKAGE_API_SLOT);
131
+ });
132
+ }
133
+
134
+ function inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report }) {
100
135
  const appsWithFiles = new Set();
101
- let readers = 0;
136
+ let operations = 0;
102
137
  for (const file of files) {
103
- const bindings = kit.resolver.classifyPath(file.rel).bindings;
104
- if (bindings?.app) appsWithFiles.add(bindings.app);
105
- const checker = kit.checkerOf(file.sourceFile);
106
- const isClient = clientRels.has(file.rel);
107
- const clientCalls = { value: 0 };
108
- kit.walk(file.sourceFile, node => inspectTransportNode({ ts, kit, checker, file, node, isClient, clientCalls, clients, owned, report }));
109
- if (isClient && clientCalls.value === 0) report(file, file.sourceFile, `${file.rel} never calls the global fetch; the client is the one module that owns fetch, with its timeout and abort signal.`);
110
- if (file.slot === API_SLOT && bindings?.app && path.posix.basename(file.rel).startsWith('read-')) {
111
- readers += 1;
112
- const reachesClient = graph.edges.some(edge => edge.from === file.rel && edge.runtime
113
- && (clientRels.has(edge.to) || graph.files.get(edge.to)?.slot === PACKAGE_API_SLOT));
114
- if (!reachesClient) report(file, file.sourceFile, `${file.rel} is a server reader that does not import the transport client; a reader fetches through the repository's one client, never through its own transport.`);
138
+ if (file.bindings?.app) appsWithFiles.add(file.bindings.app);
139
+ const scope = { ts, kit, checker: kit.checkerOf(file.sourceFile), file, isClient: clientRels.has(file.rel), transport, owned, report };
140
+ kit.walk(file.sourceFile, node => inspectTransportNode(scope, node));
141
+ if (OPERATION_SLOTS.has(file.slot) && OPERATION_FILE.test(path.posix.basename(file.rel))) {
142
+ operations += 1;
143
+ if (!reachesClient(graph, file, clientRels)) report(file, file.sourceFile, `${file.rel} is a GraphQL operation that does not import the transport client; an operation runs through the repository's one Apollo client, never through its own transport.`);
115
144
  }
116
145
  }
117
- return { appsWithFiles, readers };
146
+ return { appsWithFiles, operations };
147
+ }
148
+
149
+ /**
150
+ * The declared Next apps that call the back end: those holding a file of an api owner slot (modules/api, the app client, the app
151
+ * union) or importing a file of the api package. An app with neither shares no transport.
152
+ */
153
+ function transportApps(nextApps, files, graph) {
154
+ const byRel = new Map(files.map(file => [file.rel, file]));
155
+ const calling = new Set(files.filter(file => API_OWNER_SLOTS.has(file.slot) && file.slot !== PACKAGE_API_SLOT).map(file => file.bindings?.app));
156
+ for (const edge of graph.edges) {
157
+ const app = byRel.get(edge.from)?.bindings?.app;
158
+ if (app && (byRel.get(edge.to)?.slot ?? '').startsWith(PACKAGE_API_SLOT)) calling.add(app);
159
+ }
160
+ return nextApps.map(item => item.name).filter(name => calling.has(name));
161
+ }
162
+
163
+ /** One instance per client folder (its slot root), carried by the folder's first file. */
164
+ function clientInstances(clients) {
165
+ const byRoot = new Map();
166
+ for (const file of [...clients].sort((a, b) => a.rel.localeCompare(b.rel))) if (!byRoot.has(file.root)) byRoot.set(file.root, file);
167
+ return [...byRoot.values()];
118
168
  }
119
169
 
120
170
  export function checkTransportOwner(input) {
@@ -124,16 +174,22 @@ export function checkTransportOwner(input) {
124
174
  const violations = [];
125
175
  const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
126
176
  const nextApps = resolver.repo.apps.filter(item => item.kind === 'next');
127
- const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e');
177
+ const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e').map(file => {
178
+ const classified = resolver.classifyPath(file.rel);
179
+ return { ...file, root: classified.root ?? file.rel, bindings: classified.bindings };
180
+ });
128
181
  const clients = files.filter(file => CLIENT_SLOTS.has(file.slot));
129
- const outcomes = files.filter(file => resolver.slot(file.slot)?.outcomeHome === true);
182
+ const instances = clientInstances(clients);
183
+ const outcomes = files.filter(file => resolver.slot(file.slot)?.outcomeHome === true).map(file => ({ ...file, root: file.rel }));
130
184
  const clientRels = new Set(clients.map(file => file.rel));
131
- const list = items => items.map(item => item.rel).join(', ');
132
- const owned = clients.length ? `the repository has one transport (${list(clients)}). Call the client and take its Outcome.` : NO_CLIENT;
185
+ const transport = instances.length ? instances.map(item => item.root).join(', ') : 'no client exists yet';
186
+ const owned = instances.length ? `the repository has one transport (${transport}). Call the back end through a modules/api operation and take its GraphQLResponse.` : NO_CLIENT;
133
187
 
134
- // Exactly one client and one Outcome union per repository.
135
- for (const [items, noun] of [[clients, 'transport client'], [outcomes, 'Outcome union']]) reportMultiplicity(items, noun, nextApps, report, list);
188
+ // Exactly one client folder and one GraphQLResponse union per repository.
189
+ const calling = transportApps(nextApps, files, graph);
190
+ reportMultiplicity(instances, 'transport client', calling, report);
191
+ reportMultiplicity(outcomes, 'GraphQLResponse union', calling, report);
136
192
 
137
- const { appsWithFiles, readers } = inspectTransportFiles({ files, graph, kit, ts, clientRels, clients, owned, report });
138
- return { violations, coverage: { status: 'checked', apps: appsWithFiles.size, readers, clients: clients.length, outcomes: outcomes.length } };
193
+ const { appsWithFiles, operations } = inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report });
194
+ return { violations, coverage: { status: 'checked', apps: appsWithFiles.size, operations, clients: instances.length, outcomes: outcomes.length } };
139
195
  }
@@ -156,7 +156,10 @@ function unresolvedReferenceFinding(reference, sourceFile, project, config, work
156
156
  const codeLike = !helpers.assetExtension.test(reference.specifier);
157
157
  const workspaceImport = [...workspaceNames].some(name => helpers.sameOrUnder(reference.specifier, name));
158
158
  const internal = reference.specifier.startsWith('.') || helpers.pathAliasMatches(reference.specifier, project.options.paths) || workspaceImport;
159
- if (codeLike && internal) errors.push({
159
+ // A __generated__/ folder is the gitignored output of the app's codegen step (the GraphQL wire types, fe-stack codegen): it
160
+ // exists after `npm run codegen`, which lint, typecheck and build run first, and the compiler proves those imports there.
161
+ const generated = /(?:^|\/)__generated__(?:\/|$)/.test(reference.specifier);
162
+ if (codeLike && internal && !generated) errors.push({
160
163
  ruleId: 'ARCH_INTERNAL_IMPORT_UNRESOLVED',
161
164
  project: project.relative,
162
165
  path: helpers.relativePath(config.root, sourceFile.fileName),
@@ -22,9 +22,11 @@
22
22
  // HFS_CONTRACT_SNAPSHOT_DRIFT (R23, rules/contract.mjs) an uncommitted back-end snapshot, or a front-end copy that differs from it
23
23
  // BE_TEST_TOPOLOGY (R47, rules/test-topology.mjs) a `.test` file, a testing/ folder, a second jest configuration
24
24
  // FE_NO_TESTS (R97, rules/fe-no-tests.mjs) a front end holds a spec, e2e or test-tool file, a test script or a test dependency; no exception
25
+ // FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
26
+ // declares a package knowledge/hfs/fe-stack.yaml forbids
25
27
  // HFS_LITE_SECRET_CUSTODY (R220, rules/supabase-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
26
- // HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: a test script, dependency or tool config in a
27
- // package.json or the tree, or a declared worker app, event pattern or trigger kind lite does not have
28
+ // HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, stack or browser script, or a
29
+ // dependency of self-hosted infrastructure (test containers, fault proxy, BullMQ, Kafka, Redis)
28
30
  // DB_MIGRATION_SHAPE ... DB_TYPES_DRIFT (R213-R216, rules/database.mjs, async: passed in as extraFindings) the Supabase migrations, policies, definer functions, buckets, config.toml and generated types
29
31
  // BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
30
32
  // HFS_REPO_LOCAL_CHECK (R103, rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
@@ -78,6 +80,7 @@ import { checkAppRoot, trackedTreeView } from './architecture/hfs.mjs';
78
80
  import { testTopologyFindings } from './rules/test-topology.mjs';
79
81
  import { feNoTestsFindings } from './rules/fe-no-tests.mjs';
80
82
  import { editionFindings } from './rules/edition.mjs';
83
+ import { feStackFindings } from './rules/fe-stack.mjs';
81
84
  import { byCodeUnit } from '../lib/list.mjs';
82
85
  import { pinFindings } from './pin-findings.mjs';
83
86
  // The database rules (R213-R216) read SQL through a WASM parser, so they are async: `starci app check` calls them beside the other emitters and passes the findings in as `extraFindings`.
@@ -100,7 +103,7 @@ export const CHECK_CODES = Object.freeze([
100
103
  'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
101
104
  'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
102
105
  'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
103
- 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY',
106
+ 'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY',
104
107
  'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
105
108
  'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
106
109
  'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
@@ -242,11 +245,12 @@ function minimumInstanceFindings(resolver, required, instances) {
242
245
  return findings;
243
246
  }
244
247
 
245
- function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration }) {
248
+ function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration, manifest }) {
246
249
  const findings = [
247
250
  ...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }),
248
251
  ...supabaseSecretFindings({ repoRoot, files: files.filter((file) => inScope(file)), resolver, repo }),
249
- ...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, withDeclaration: editionDeclaration }),
252
+ ...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, manifest, withDeclaration: editionDeclaration }),
253
+ ...feStackFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, manifest }),
250
254
  ...repoLocalCheckFindings({ repoRoot, files }),
251
255
  ...lintSuppressionFindings({ repoRoot, files }),
252
256
  ];
@@ -295,7 +299,7 @@ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, sco
295
299
  if (repo.kind === RUNTIME_KIND) return findings;
296
300
 
297
301
  const pins = readPins(root);
298
- findings.push(...contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration }));
302
+ findings.push(...contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration, manifest }));
299
303
  if (!isRoot) findings.push(...sizeSoftFindings({ repoRoot, resolver, files, inScope }));
300
304
  return findings;
301
305
  }
@@ -1,5 +1,7 @@
1
1
  // edition.mjs - the one edition filter shared by the HFS slot resolver, declaration validation, rule catalog and
2
2
  // edition-specific rule parameters. Full is the default; lite changes data views, never the checks that consume them.
3
+ // Lite is the full architecture deployed without self-hosted infrastructure (owner ruling 2026-10-12): its overlays swap
4
+ // adapters and remove the infra and e2e world, and every slot keeps its own `tests`.
3
5
  import { isPlainObject } from '../../engine/plain-object.mjs';
4
6
  import { EDITIONS } from './manifest-shape.mjs';
5
7
 
@@ -15,7 +17,7 @@ export function slotInEdition(manifest, slot, edition) {
15
17
  return (slot.editions ?? manifest.editions ?? EDITIONS).includes(edition);
16
18
  }
17
19
 
18
- /** The slot as `edition` sees it: lite overlays its data, presence and tracking while forbidding every test world. */
20
+ /** The slot as `edition` sees it: lite overlays its data, presence and tracking; `tests` stays the slot's own (lite keeps the unit specs). */
19
21
  export function effectiveSlot(slot, profile, edition) {
20
22
  if (edition !== 'lite') return slot;
21
23
  const { lite, litePresence, ...base } = slot;
@@ -23,7 +25,7 @@ export function effectiveSlot(slot, profile, edition) {
23
25
  let tracked = slot.tracked;
24
26
  if (presence === 'forbidden') tracked = 'external';
25
27
  else if (tracked === 'external') tracked = 'tracked';
26
- return { ...base, ...lite, presence, tracked, tests: 'none' };
28
+ return { ...base, ...lite, presence, tracked };
27
29
  }
28
30
 
29
31
  /** The template group starci app sync renders `slot` from under `edition`: `liteManagedBy` (else `managedBy`) in lite, `managedBy` otherwise; undefined when unmanaged. */
@@ -59,10 +61,17 @@ export function enforcerJudgedInEdition(rules, kind, id, edition = 'full') {
59
61
  return owners.every((rule) => judgedInEdition(rule, edition) && rule.enforcers.filter((enforcer) => enforcer.kind === kind && enforcer.id === id).every((enforcer) => judgedInEdition(enforcer, edition)));
60
62
  }
61
63
 
62
- /** A frozen copy of one profile's parameters, with its lite overrides applied only in the lite edition. */
64
+ /**
65
+ * A frozen copy of one profile's parameters, with its lite overrides applied only in the lite edition. A lite key whose base
66
+ * value and override are both maps merges one level deep (lite.infraOwners restates only the specifiers whose owner changes);
67
+ * any other value replaces the base.
68
+ */
63
69
  export function editionRuleParams(params, edition) {
64
70
  const { lite, ...base } = params;
65
- const merged = edition === 'lite' && lite ? { ...base, ...lite } : base;
71
+ const merged = edition === 'lite' && lite ? { ...base } : base;
72
+ if (edition === 'lite' && lite) {
73
+ for (const [key, value] of Object.entries(lite)) merged[key] = isPlainObject(value) && isPlainObject(base[key]) ? { ...base[key], ...value } : value;
74
+ }
66
75
  const deepFreeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); } return Object.freeze(value); };
67
76
  return deepFreeze(structuredClone(merged));
68
77
  }
@@ -86,9 +86,9 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
86
86
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
87
87
  const strList = (v) => stringList(v);
88
88
 
89
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'anonymousActions'];
89
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'];
90
90
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
91
- const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances'];
91
+ const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
92
92
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
93
93
  const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'generatedBy', 'parent', 'coverage'];
94
94
 
@@ -197,6 +197,7 @@ function slotLiteOverlayProblems(slot, at) {
197
197
  if (slot.lite === undefined) return [];
198
198
  const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
199
199
  && (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
200
+ && (slot.lite.why === undefined || (typeof slot.lite.why === 'string' && slot.lite.why.trim()))
200
201
  && ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
201
202
  && (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
202
203
  && (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
@@ -211,7 +212,6 @@ function slotEntryProblems(slot, at) {
211
212
  else if (typeof slot.path !== 'string' || !slot.path.endsWith('/') || slot.owner === true) bad.push(`${at}: entries belong to a directory slot that is not an owner (they name extra public entries of the owner that holds it)`);
212
213
  else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
213
214
  }
214
- if (slot.anonymousActions !== undefined && !(strList(slot.anonymousActions) && slot.anonymousActions.length > 0 && new Set(slot.anonymousActions).size === slot.anonymousActions.length && slot.anonymousActions.every(relPath) && typeof slot.path === 'string' && slot.path.endsWith('/'))) bad.push(`${at}: anonymousActions must be a non-empty list of unique relative file paths of a directory slot`);
215
215
  if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
216
216
  return bad;
217
217
  }
@@ -15,7 +15,9 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
15
15
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
16
16
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
17
17
  const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
18
- const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers']);
18
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers', 'cases']);
19
+ const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote']);
20
+ const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
19
21
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions']);
20
22
 
21
23
  function vocabularyProblems(catalog, key, names, bad) {
@@ -59,6 +61,23 @@ function checkRuleText(rule, label, bad) {
59
61
  if (rule.scope !== undefined && rule.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
60
62
  }
61
63
 
64
+ /** The dated owner cases of a rule: each {id case-N, date, source owner, ruling, quote?}, ids unique (an owner ruling recorded where the law lives). */
65
+ function checkRuleCases(rule, label, bad) {
66
+ if (rule.cases === undefined) return;
67
+ if (!Array.isArray(rule.cases) || !rule.cases.length) { bad.push(`${label}.cases must be a non-empty list`); return; }
68
+ const ids = new Set();
69
+ rule.cases.forEach((item, index) => {
70
+ const at = `${label}.cases[${index}]`;
71
+ if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
72
+ for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
73
+ if (typeof item.id !== 'string' || !/^case-[1-9][0-9]*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
74
+ ids.add(item.id);
75
+ if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
76
+ if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
77
+ for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])]) if (typeof item[key] !== 'string' || !item[key].trim()) bad.push(`${at}.${key} must be text`);
78
+ });
79
+ }
80
+
62
81
  function checkRuleGates(label, gates, bad) {
63
82
  if (!gates.length) return;
64
83
  if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
@@ -120,6 +139,7 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
120
139
  }
121
140
  checkRuleIdentity(rule, index, catalog, at, label, bad);
122
141
  checkRuleText(rule, label, bad);
142
+ checkRuleCases(rule, label, bad);
123
143
  enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
124
144
  bad.push(...ruleEditionProblems(rule, label));
125
145
  const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
@@ -9,5 +9,9 @@ export const DB_CONFIG_POLICY = 'DB_CONFIG_POLICY';
9
9
  export const DB_TYPES_DRIFT = 'DB_TYPES_DRIFT';
10
10
 
11
11
  export const MIGRATIONS_DIR = 'supabase/migrations';
12
+ /** The migrations retired at a baseline cutover (slot app.supabase.migrations.archive): never applied, never judged by L02-L08. */
13
+ export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/_archive`;
14
+ /** True for a migration the Supabase CLI applies: a path under supabase/migrations/ outside the archive. */
15
+ export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
12
16
  export const CONFIG_FILE = 'supabase/config.toml';
13
17
  export const TYPES_FILE = 'supabase/types/database.types.ts';
@@ -8,7 +8,7 @@ import { withoutGitLocalEnv } from '../../lib/git.mjs';
8
8
  import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
9
9
  import { sameText } from '../../lib/same-text.mjs';
10
10
  import { found, readText } from './read.mjs';
11
- import { DB_MIGRATION_SHAPE, MIGRATIONS_DIR } from './database-constants.mjs';
11
+ import { DB_MIGRATION_SHAPE, isActiveMigration, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
12
12
  const MIGRATION_NAME = /^(\d{14})_([a-z0-9]+(?:-[a-z0-9]+)*)\.sql$/;
13
13
 
14
14
  /** `<ts14>` -> the UTC instant it names, or null when the digits are no real time (month 13, day 32, ...). */
@@ -49,7 +49,7 @@ async function baseMigrationNames(git, repoRoot, sha) {
49
49
  const result = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', sha, '--', MIGRATIONS_DIR]);
50
50
  if (!result.ok) return [];
51
51
  const prefix = `${MIGRATIONS_DIR}/`;
52
- return result.stdout.split('\0').filter(Boolean).map((entry) => (entry.startsWith(prefix) ? entry.slice(prefix.length) : entry));
52
+ return result.stdout.split('\0').filter(Boolean).filter(isActiveMigration).map((entry) => entry.slice(prefix.length));
53
53
  }
54
54
 
55
55
  async function baseFileText(git, repoRoot, sha, file) {
@@ -102,16 +102,20 @@ async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase,
102
102
  return [];
103
103
  }
104
104
 
105
- function removedMigrationFindings(local, baseNames, baseSha) {
105
+ function removedMigrationFindings(local, retired, baseNames, baseSha) {
106
106
  const findings = [];
107
107
  for (const name of baseNames) {
108
- if (!local.some((entry) => entry.name === name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
108
+ if (!local.some((entry) => entry.name === name) && !retired.has(name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
109
109
  }
110
110
  return findings;
111
111
  }
112
112
 
113
- /** L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. */
114
- export async function migrationShapeFindings({ repoRoot, migrations, git, base, now }) {
113
+ /**
114
+ * L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. `archived` are the files of
115
+ * supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a base migration moved there at a baseline
116
+ * cutover is retired, not removed.
117
+ */
118
+ export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base, now }) {
115
119
  const prefix = `${MIGRATIONS_DIR}/`;
116
120
  const local = migrations.map((file) => {
117
121
  const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
@@ -127,6 +131,7 @@ export async function migrationShapeFindings({ repoRoot, migrations, git, base,
127
131
  await eachInOrder(local, async (entry) => {
128
132
  findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax));
129
133
  });
130
- findings.push(...removedMigrationFindings(local, baseNames, baseSha));
134
+ const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
135
+ findings.push(...removedMigrationFindings(local, retired, baseNames, baseSha));
131
136
  return findings;
132
137
  }
@@ -4,7 +4,7 @@ import { found, readJson, readText } from './read.mjs';
4
4
  import { configFindings, parseToml, typesFindings } from './database-config.mjs';
5
5
  import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
6
6
  import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
7
- import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE, MIGRATIONS_DIR, TYPES_FILE } from './database-constants.mjs';
7
+ import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE, isActiveMigration, MIGRATIONS_ARCHIVE_DIR, TYPES_FILE } from './database-constants.mjs';
8
8
  import { byCodeUnit } from '../../lib/list.mjs';
9
9
  import { eachInOrder } from '../../lib/in-order.mjs';
10
10
 
@@ -53,7 +53,8 @@ async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, find
53
53
  */
54
54
  export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes, now = () => Date.now() } = {}) {
55
55
  const findings = [];
56
- const migrations = files.filter((file) => file.startsWith(`${MIGRATIONS_DIR}/`)).sort(byCodeUnit);
56
+ const migrations = files.filter(isActiveMigration).sort(byCodeUnit);
57
+ const archived = files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`));
57
58
  const declaration = readJson(repoRoot, 'hfs.json');
58
59
  const declared = supabase === undefined ? declaration?.supabase : supabase;
59
60
  const hasConfig = files.includes(CONFIG_FILE);
@@ -70,7 +71,7 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, supab
70
71
  const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
71
72
  const exposed = new Set(['public', ...apiSchemas]);
72
73
  const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
73
- findings.push(...await migrationShapeFindings({ repoRoot, migrations, git: git ?? defaultGit, base, now }));
74
+ findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base, now }));
74
75
 
75
76
  const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
76
77
  findings.push(