@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
@@ -1,22 +1,27 @@
1
1
  // edition.mjs - L01, the edition rule. An hfs.json edition is `full` or `lite` (absent means full); under lite the standard is a
2
- // filter plus a few additions over the same manifest, never a second engine. The slot layer already answers the paths lite
3
- // removed (slots lite excludes, or whose litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this
4
- // rule reports what no path can state:
5
- // - inside a package.json: a test script (`test`, `test:*`, `typecheck:tests`, a jest/vitest/playwright/mocha/cypress or
6
- // `node --test` command) or a jest/vitest/playwright dependency - the same vocabulary FE_NO_TESTS scans on the fe side;
7
- // - a test-tool configuration file no more specific slot already forbids;
8
- // - a declaration of what lite does not have: a worker app, an event pattern (event-bus, queue, fenced-job, projection,
9
- // saga) or its trigger kinds (saga, reactors, jobs, realtime). The forbidden names are DERIVED - a pattern, trigger or
10
- // app kind is gone under lite exactly when every slot that carries it is litePresence: forbidden - so adding or
11
- // restoring a kind is a manifest edit, not an edit here.
12
- // The edition is never tested here: a full app's view forbids none of these names and keeps its tests, so the rule answers [].
2
+ // filter plus a few additions over the same manifest, never a second engine. Lite is the full architecture deployed without
3
+ // self-hosted infrastructure (owner ruling 2026-10-12): the same slots, patterns and unit specs, Supabase adapters behind the same
4
+ // ports, and no e2e or contract world. The slot layer already answers the paths lite removed (slots lite excludes, or whose
5
+ // litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this rule reports what no path can state:
6
+ // - inside a package.json: an e2e, contract, stack or browser script (`test:e2e`, `test:contract`, `test:stack`, `test:browser`,
7
+ // with a pre/post prefix too) or a dependency of the infrastructure lite does not run - a test-world container or proxy
8
+ // (testcontainers, toxiproxy) or a browser runner (playwright, cypress), and every library the lite infraOwners own nowhere
9
+ // while full owns them (BullMQ, Kafka, Redis: lite runs pgmq and Postgres locks instead);
10
+ // - a declaration of what lite does not have: an app kind, pattern or trigger kind every slot of which is forbidden under
11
+ // lite. The forbidden names are DERIVED from the manifest, so adding or restoring a kind is a manifest edit, not an edit here
12
+ // (under the 2026-10-12 ruling no pattern, trigger or app kind is gone).
13
+ // A full app answers []: the rule tests the edition once and a full view forbids none of these names.
13
14
  import { found, readJson } from './read.mjs';
14
- import { DEPENDENCY_SECTIONS, TEST_DEPENDENCY, TEST_RUNNER_COMMAND, TEST_SCRIPT_NAME, TEST_TOOL_FILE } from './fe-no-tests.mjs';
15
+ import { DEPENDENCY_SECTIONS } from './fe-no-tests.mjs';
16
+ import { editionRuleParams } from '../edition-slots.mjs';
15
17
 
16
18
  export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
17
19
 
18
20
  const PACKAGE_JSON = /(?:^|\/)package\.json$/;
19
- const TYPECHECK_TESTS = /^(?:pre|post)?typecheck:tests$/;
21
+ /** The scripts of a world lite does not have: the e2e and contract layers, the self-hosted stack and the browser journey. */
22
+ const LITE_FORBIDDEN_SCRIPT = /^(?:pre|post)?test:(?:e2e|contract|stack|browser)$/;
23
+ /** The dependencies of a test infrastructure lite does not run: containers, fault proxies and browser runners. */
24
+ const LITE_FORBIDDEN_TEST_INFRA = /^(?:testcontainers|@testcontainers\/.+|toxiproxy-node-client|playwright|playwright-core|@playwright\/.+|cypress)$/;
20
25
 
21
26
  /**
22
27
  * The names the edition cannot declare: for each of `pattern`, `trigger` and `appKind`, the value is gone when every slot of
@@ -47,32 +52,43 @@ function editionGone(resolver) {
47
52
 
48
53
  const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
49
54
 
50
- function testScriptFindings(rel, pkg) {
55
+ /**
56
+ * The specifiers a lite back end may import nowhere although full gives them an owner: the self-hosted infrastructure libraries
57
+ * the lite infraOwners override removes (bullmq, kafkajs, ioredis, ...). Read from the two rule-parameter views, never listed here.
58
+ */
59
+ function liteRemovedLibraries(manifest) {
60
+ const be = manifest?.ruleParams?.be;
61
+ if (!be) return new Set();
62
+ const lite = editionRuleParams(be, 'lite').infraOwners ?? {};
63
+ const full = editionRuleParams(be, 'full').infraOwners ?? {};
64
+ return new Set(Object.keys(lite).filter((name) => lite[name].length === 0 && (full[name]?.length ?? 0) > 0));
65
+ }
66
+
67
+ function scriptFindings(rel, pkg) {
51
68
  const scripts = isPlainObject(pkg.scripts) ? pkg.scripts : {};
52
- return Object.entries(scripts)
53
- .filter(([name, command]) => TEST_SCRIPT_NAME.test(name) || TYPECHECK_TESTS.test(name) || TEST_RUNNER_COMMAND.test(String(command)))
54
- .map(([name]) => found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} has script ${JSON.stringify(name)}: the lite edition has no tests - a lite app does not typecheck or run a test world`, { name }));
69
+ return Object.keys(scripts)
70
+ .filter((name) => LITE_FORBIDDEN_SCRIPT.test(name))
71
+ .map((name) => found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} has script ${JSON.stringify(name)}: a lite app has unit specs and optional integration specs on the local Supabase stack, never an e2e, contract, stack or browser run`, { name }));
55
72
  }
56
73
 
57
- function testDependencyFindings(rel, pkg) {
74
+ function dependencyFindings(rel, pkg, removed) {
58
75
  const findings = [];
59
76
  for (const section of DEPENDENCY_SECTIONS) {
60
77
  const deps = pkg[section];
61
78
  if (!isPlainObject(deps)) continue;
62
79
  for (const name of Object.keys(deps)) {
63
- if (TEST_DEPENDENCY.test(name)) {
64
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: the lite edition has no tests - the dependency belongs to the full edition`, { section, name }));
65
- }
80
+ if (LITE_FORBIDDEN_TEST_INFRA.test(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: a lite app runs no test containers, fault proxy or browser journey - its integration specs run against \`supabase start\``, { section, name }));
81
+ else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite runs no self-hosted infrastructure - the pattern's port is implemented on Supabase (pgmq, Postgres locks)`, { section, name }));
66
82
  }
67
83
  }
68
84
  return findings;
69
85
  }
70
86
 
71
87
  /** The findings one package.json's scripts and dependencies produce under lite. */
72
- function packageFindings(repoRoot, rel) {
88
+ function packageFindings(repoRoot, rel, removed) {
73
89
  const pkg = readJson(repoRoot, rel);
74
90
  if (!isPlainObject(pkg)) return [];
75
- return [...testScriptFindings(rel, pkg), ...testDependencyFindings(rel, pkg)];
91
+ return [...scriptFindings(rel, pkg), ...dependencyFindings(rel, pkg, removed)];
76
92
  }
77
93
 
78
94
  const goneAppFindings = (repo, gone) => (repo.apps ?? [])
@@ -93,26 +109,22 @@ function declarationFindings(repo, gone) {
93
109
  }
94
110
 
95
111
  /**
96
- * L01 for one scope (the app root or one side), over `files` (scope-relative). Both questions are answered by the
97
- * resolver's view of the edition: the names gone from it, and whether any slot of it still has tests. The declaration
98
- * findings are judged from the be side (its apps, patterns and kinds): the root scope looks at sides.be, a standalone
99
- * be-side check at its own view. `withDeclaration: false` (a side scope of a whole-app check) skips them: the root scope
100
- * already reported them on hfs.json, and the side scope would only repeat them on a path that is not this side's.
112
+ * L01 for one scope (the app root or one side), over `files` (scope-relative). The declaration findings are judged from the be
113
+ * side (its apps, patterns and kinds): the root scope looks at sides.be, a standalone be-side check at its own view.
114
+ * `withDeclaration: false` (a side scope of a whole-app check) skips them: the root scope already reported them on hfs.json.
115
+ * The package findings read every package.json of the scope under lite.
101
116
  */
102
- export function editionFindings({ repoRoot, files, repo, resolver, withDeclaration = true }) {
117
+ export function editionFindings({ repoRoot, files, repo, resolver, manifest, withDeclaration = true }) {
118
+ if ((repo.edition ?? 'full') !== 'lite') return [];
103
119
  const gone = editionGone(resolver);
104
- const noTestWorld = resolver.slots().every((slot) => slot.tests === 'none');
105
120
  let beView = null;
106
121
  if (withDeclaration && repo.profile === 'app') beView = repo.sides?.be;
107
122
  else if (withDeclaration && repo.profile === 'be') beView = repo;
108
123
  const findings = beView ? declarationFindings(beView, gone) : [];
109
- for (const file of noTestWorld ? files : []) {
110
- if (file.includes('node_modules/')) continue;
111
- if (PACKAGE_JSON.test(file)) {
112
- findings.push(...packageFindings(repoRoot, file));
113
- } else if (TEST_TOOL_FILE.test(file) && resolver.classifyPath(file).status !== 'forbidden') {
114
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, file, `${file} is test tooling: the lite edition has no tests - the file belongs to the full edition`));
115
- }
124
+ const removed = liteRemovedLibraries(manifest);
125
+ for (const file of files) {
126
+ if (file.includes('node_modules/') || !PACKAGE_JSON.test(file)) continue;
127
+ findings.push(...packageFindings(repoRoot, file, removed));
116
128
  }
117
129
  return findings;
118
130
  }
@@ -12,13 +12,12 @@ import { found, readJson } from './read.mjs';
12
12
  export const FE_NO_TESTS = 'FE_NO_TESTS';
13
13
  const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
14
14
  const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
15
- export const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
- // The edition rule (rules/edition.mjs, L01) applies the same lists to the whole app tree in lite: they are the test vocabulary.
17
- export const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
15
+ const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
+ const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
18
17
  const TEST_RUNNER_COMMAND_SOURCE = String.raw`(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b`;
19
- export const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
18
+ const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
20
19
  const TEST_DEPENDENCY_SOURCE = String.raw`^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$`;
21
- export const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
20
+ const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
22
21
  export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
23
22
 
24
23
  /** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
@@ -0,0 +1,71 @@
1
+ // fe-stack.mjs - FE_STACK_FORBIDDEN: a front end depends only on the canonical library of each front-end concern (owner ruling
2
+ // 2026-10-13, lite = full). The catalog knowledge/hfs/fe-stack.yaml lists, per concern, the canonical packages and the alternatives
3
+ // it `forbids`; a manifest that declares a forbidden package is a finding naming the concern and its canonical packages.
4
+ // - the fe side: every package.json of the side (the apps' and the packages' manifests), every dependency section;
5
+ // - the app root: the one package.json carries both sides, so a forbidden package there is a finding unless the back end owns it
6
+ // (a key of the be `infraOwners` rule parameter with an owner, e.g. graphql-ws or moment for platform/realtime and
7
+ // platform/clock): a back-end library is not a front-end dependency.
8
+ // The import-level half (a forbidden package imported by a front-end source file) is the eslint canon's.
9
+ import fs from 'node:fs';
10
+ import { parseYaml } from '../../../engine/yaml.mjs';
11
+ import { found, readJson } from './read.mjs';
12
+ import { DEPENDENCY_SECTIONS } from './fe-no-tests.mjs';
13
+ import { editionRuleParams } from '../edition-slots.mjs';
14
+
15
+ export const FE_STACK_FORBIDDEN = 'FE_STACK_FORBIDDEN';
16
+ const CATALOG_FILE = new URL('../../../knowledge/hfs/fe-stack.yaml', import.meta.url);
17
+ const PACKAGE_JSON = /(?:^|\/)package\.json$/;
18
+ const APP_ROOT_MANIFEST = 'package.json';
19
+
20
+ let forbidden = null;
21
+
22
+ /** name -> {concern, canonical} for every package a concern of the catalog forbids, read once. */
23
+ function forbiddenPackages() {
24
+ if (forbidden) return forbidden;
25
+ const catalog = parseYaml(fs.readFileSync(CATALOG_FILE, 'utf8'));
26
+ if (catalog?.schema !== 'starci/fe-stack@1' || !Array.isArray(catalog.concerns)) throw new Error('knowledge/hfs/fe-stack.yaml is not a starci/fe-stack@1 catalog');
27
+ forbidden = new Map();
28
+ for (const concern of catalog.concerns) {
29
+ for (const name of concern.forbids ?? []) forbidden.set(name, { concern: concern.id, canonical: concern.packages ?? [] });
30
+ }
31
+ return forbidden;
32
+ }
33
+
34
+ /**
35
+ * The packages the back end owns (be infraOwners with at least one owner, full's view: a library lite removes is the edition rule's
36
+ * finding, not a front-end one): a root dependency of the back end.
37
+ */
38
+ function backendOwned(manifest) {
39
+ const be = manifest?.ruleParams?.be;
40
+ if (!be) return new Set();
41
+ const owners = editionRuleParams(be, 'full').infraOwners ?? {};
42
+ return new Set(Object.keys(owners).filter((name) => Array.isArray(owners[name]) && owners[name].length > 0));
43
+ }
44
+
45
+ function manifestFindings(repoRoot, rel, skip) {
46
+ const pkg = readJson(repoRoot, rel);
47
+ if (!pkg || typeof pkg !== 'object') return [];
48
+ const catalog = forbiddenPackages();
49
+ const findings = [];
50
+ for (const section of DEPENDENCY_SECTIONS) {
51
+ for (const name of Object.keys(pkg[section] ?? {})) {
52
+ const entry = catalog.get(name);
53
+ if (!entry || skip.has(name)) continue;
54
+ const canonical = entry.canonical.length ? entry.canonical.join(', ') : 'the canonical library of the concern';
55
+ findings.push(found(FE_STACK_FORBIDDEN, rel, `${rel} ${section} declares ${name}, which the front-end stack forbids for the ${entry.concern} concern (knowledge/hfs/fe-stack.yaml); use ${canonical} and delete ${name}`, { section, dependency: name, concern: entry.concern }));
56
+ }
57
+ }
58
+ return findings;
59
+ }
60
+
61
+ /**
62
+ * FE_STACK_FORBIDDEN over the tracked paths `files` of one scope: every package.json of the fe side, or the app root's one
63
+ * package.json minus what the back end owns. A be side has no front-end manifest and answers [].
64
+ */
65
+ export function feStackFindings({ repoRoot, files, repo, manifest }) {
66
+ if (repo.profile === 'be') return [];
67
+ const atRoot = repo.profile !== 'fe';
68
+ const skip = atRoot ? backendOwned(manifest) : new Set();
69
+ const manifests = files.filter((file) => !file.includes('node_modules/') && (atRoot ? file === APP_ROOT_MANIFEST : PACKAGE_JSON.test(file)));
70
+ return manifests.flatMap((rel) => manifestFindings(repoRoot, rel, skip));
71
+ }
@@ -5,9 +5,15 @@ import {
5
5
  readRepoDeclaration,
6
6
  resolveRepoDeclaration,
7
7
  } from "../runtime/scripts/hfs/slots.mjs";
8
- import { imageFiles, TEMPLATES_DIR } from "../sync/index.mjs";
8
+ import { imageFiles, render, TEMPLATES_DIR } from "../sync/index.mjs";
9
9
  import { ScaffoldError, pascalOf } from "./service.mjs";
10
- import { jsonText, packageJsonText } from "./app.mjs";
10
+ import { PARTIAL_VARIABLES, coreFeApps, jsonText, packageJsonText } from "./app.mjs";
11
+
12
+ /** The shared fe app core (modules/api, auth, toast, redux, the providers, the hooks): the base tree, then the edition overlay. */
13
+ const CORE_DIRS = Object.freeze({
14
+ full: ["skeleton/apps/__app__"],
15
+ lite: ["skeleton/apps/__app__", "skeleton-lite/apps/__app__"],
16
+ });
11
17
 
12
18
  const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
13
19
 
@@ -30,8 +36,9 @@ const fill = (text, values, file) =>
30
36
  });
31
37
 
32
38
  /**
33
- * Adds one Next workspace from the edition's canonical app skeleton, then registers it in hfs.json.
34
- * The root workspace glob is added only when an older app manifest does not already carry it.
39
+ * Adds one Next workspace from the edition's canonical app skeleton and the shared app core (both editions, owner ruling
40
+ * 2026-10-13: lite and full have one fe structure), then registers it in hfs.json. The root workspace glob is added only when an
41
+ * older app manifest does not already carry it.
35
42
  */
36
43
  export function addApp({ root, name }) {
37
44
  if (!NAME.test(String(name)))
@@ -53,11 +60,6 @@ export function addApp({ root, name }) {
53
60
  "HFS_ADD_NO_FRONT_END",
54
61
  "this app has no front-end side",
55
62
  );
56
- if (repo.edition === "lite")
57
- throw new ScaffoldError(
58
- "HFS_ADD_LITE_SINGLE_APP",
59
- "a lite app has one front-end app; a second app needs the shared <project>-ui and <project>-i18n packages: run starci app upgrade --edition full",
60
- );
61
63
  if (repo.sides.fe.apps.some((app) => app.name === name))
62
64
  throw new ScaffoldError(
63
65
  "HFS_ADD_EXISTS",
@@ -80,18 +82,22 @@ export function addApp({ root, name }) {
80
82
  appPascal: pascalOf(name),
81
83
  };
82
84
  const targetBase = `fe/apps/${name}`;
83
- const planned = listFiles(source).map((relative) => ({
84
- relative: `${targetBase}/${relative}`,
85
- body: fill(
86
- fs
87
- .readFileSync(path.join(source, ...relative.split("/")), "utf8")
88
- .replace(/\r\n/g, "\n"),
85
+ const readTemplate = (dir, relative) =>
86
+ fill(
87
+ render(fs.readFileSync(path.join(dir, ...relative.split("/")), "utf8").replace(/\r\n/g, "\n"), PARTIAL_VARIABLES),
89
88
  values,
90
89
  relative,
91
- ),
92
- }));
90
+ );
91
+ // The app skeleton, then the shared core, then the edition overlay: a later tree replaces an earlier file of the same path.
92
+ const byPath = new Map();
93
+ for (const dir of [source, ...CORE_DIRS[repo.edition === "lite" ? "lite" : "full"].map((core) => path.join(TEMPLATES_DIR, "fe", ...core.split("/")))]) {
94
+ if (!fs.existsSync(dir)) continue;
95
+ for (const relative of listFiles(dir)) byPath.set(relative, { relative: `${targetBase}/${relative}`, body: readTemplate(dir, relative) });
96
+ }
97
+ const planned = [...byPath.values()];
93
98
 
94
- const firstApp = repo.sides.fe.apps.find((app) => app.kind === "next");
99
+ // The dependency source is a product app (it carries the core's fe-stack packages), else the first Next app.
100
+ const firstApp = coreFeApps(repo.sides.fe.apps)[0] ?? repo.sides.fe.apps.find((app) => app.kind === "next");
95
101
  const firstPackage = firstApp
96
102
  ? path.join(root, "fe", "apps", firstApp.name, "package.json")
97
103
  : null;
@@ -14,9 +14,6 @@ const read = (relative) =>
14
14
  .replace(/\r\n/g, "\n");
15
15
  const PINS_FILE = path.join(import.meta.dirname, "..", "runtime", "knowledge", "hfs", "canon-pins.yaml");
16
16
 
17
- /** Lite has no test world, so the existing cli tree is selected without its colocated full-edition specs. */
18
- export const selectLiteCliEntries = (entries) =>
19
- entries.filter((entry) => !entry.path.endsWith(".spec.ts"));
20
17
 
21
18
  const managedScripts = (repo) => {
22
19
  const fragment = appScripts(repo).replace(/,\s*$/, "");
@@ -73,6 +70,25 @@ export class RunSeedsCli extends CommandRunner {
73
70
  }
74
71
  `;
75
72
 
73
+ const SEED_RUN_SPEC = `import { mock } from "@starci/jest-preset"
74
+ import { Test } from "@nestjs/testing"
75
+ import { SeedRunnerService } from "@modules/platform/database"
76
+ import { RunSeedsCli } from "./run.cli"
77
+
78
+ describe("RunSeedsCli", () => {
79
+ it("delegates the tracked seed run to the seed runner once", async () => {
80
+ const seeds = mock<SeedRunnerService>()
81
+ const moduleRef = await Test.createTestingModule({
82
+ providers: [RunSeedsCli, { provide: SeedRunnerService, useValue: seeds }],
83
+ }).compile()
84
+
85
+ await expect(moduleRef.get(RunSeedsCli).run()).resolves.toBeUndefined()
86
+
87
+ expect(seeds.run).toHaveBeenCalledTimes(1)
88
+ })
89
+ })
90
+ `;
91
+
76
92
  const SEED_MODULE = `import { Module } from "@nestjs/common"
77
93
  import { SeedCli } from "./seed.cli"
78
94
  import { RunSeedsCli } from "./subs/run.cli"
@@ -188,6 +204,15 @@ export function ensureLiteCli({ root, manifest, repo }) {
188
204
  read("be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts"),
189
205
  ],
190
206
  ["be/src/features/cli/migrate/subs/run.cli.ts", MIGRATE_RUN],
207
+ // Owner ruling 2026-10-12: each cli command of a lite back end owes its unit spec, as in full (ruleParams.be.unitRoles).
208
+ [
209
+ "be/src/features/cli/migrate/migrate.cli.spec.ts",
210
+ read("be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts"),
211
+ ],
212
+ [
213
+ "be/src/features/cli/migrate/subs/run.cli.spec.ts",
214
+ read("be/skeleton/src/features/cli/migrate/subs/run.cli.spec.ts"),
215
+ ],
191
216
  [
192
217
  "be/src/features/cli/seed/seed.cli.ts",
193
218
  read("be/skeleton/src/features/cli/seed/seed.cli.ts"),
@@ -201,6 +226,11 @@ export function ensureLiteCli({ root, manifest, repo }) {
201
226
  read("be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts"),
202
227
  ],
203
228
  ["be/src/features/cli/seed/subs/run.cli.ts", SEED_RUN],
229
+ [
230
+ "be/src/features/cli/seed/seed.cli.spec.ts",
231
+ read("be/skeleton/src/features/cli/seed/seed.cli.spec.ts"),
232
+ ],
233
+ ["be/src/features/cli/seed/subs/run.cli.spec.ts", SEED_RUN_SPEC],
204
234
  ];
205
235
  declaration.sides.be.apps.push({ name: "cli", kind: "cli" });
206
236
  declaration.sides.be.kinds = [
@@ -25,12 +25,18 @@ const template = (relative, values) => {
25
25
  });
26
26
  };
27
27
 
28
- /** Lite replaces only the webhook door; the full-edition pattern remains byte-identical. */
28
+ /** The lite templates of the webhook door and its unit spec: the door hands the delivery id to the provider inbox. */
29
+ const LITE_WEBHOOK_TEMPLATES = Object.freeze({
30
+ "webhooks/webhook.door.ts.tpl": "../inbox/webhook-door.ts.tpl",
31
+ "webhooks/webhook.door.spec.ts.tpl": "../inbox/webhook-door.spec.ts.tpl",
32
+ });
33
+
34
+ /** Lite replaces only the webhook door and its spec; the full-edition pattern remains byte-identical. */
29
35
  export const selectLiteWebhookEntries = (entries) =>
30
36
  entries.map((entry) =>
31
- entry.template === "webhooks/webhook.door.ts.tpl"
32
- ? { ...entry, template: "../inbox/webhook-door.ts.tpl" }
33
- : entry,
37
+ LITE_WEBHOOK_TEMPLATES[entry.template] === undefined
38
+ ? entry
39
+ : { ...entry, template: LITE_WEBHOOK_TEMPLATES[entry.template] },
34
40
  );
35
41
 
36
42
  const matchingBrace = (text, open) => {
@@ -252,6 +258,10 @@ export function addLiteWebhookInbox({
252
258
  relative: `${base}/${provider}-inbox.service.ts`,
253
259
  text: template("service.ts.tpl", renderValues),
254
260
  },
261
+ {
262
+ relative: `${base}/${provider}-inbox.service.spec.ts`,
263
+ text: template("service.spec.ts.tpl", renderValues),
264
+ },
255
265
  {
256
266
  relative: `${base}/persistence/${provider}-inbox.sql.ts`,
257
267
  text: template("sql.ts.tpl", renderValues),
@@ -280,10 +290,16 @@ export function addLiteWebhookInbox({
280
290
  "webhook-inbox.client.ts",
281
291
  );
282
292
  if (!fs.existsSync(databaseClient))
283
- planned.push({
284
- relative: "be/src/modules/platform/database/webhook-inbox.client.ts",
285
- text: template("database-client.ts.tpl", renderValues),
286
- });
293
+ planned.push(
294
+ {
295
+ relative: "be/src/modules/platform/database/webhook-inbox.client.ts",
296
+ text: template("database-client.ts.tpl", renderValues),
297
+ },
298
+ {
299
+ relative: "be/src/modules/platform/database/webhook-inbox.client.spec.ts",
300
+ text: template("database-client.spec.ts.tpl", renderValues),
301
+ },
302
+ );
287
303
  const clashes = planned
288
304
  .filter((entry) => fs.existsSync(path.join(root, ...entry.relative.split("/"))))
289
305
  .map((entry) => entry.relative);