@starci/hfs 4.0.10 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -4,11 +4,11 @@
4
4
  // (sides, app kinds) and refuses a manifest whole on any problem (HFS_MANIFEST_INVALID). Pure: every function takes a
5
5
  // parsed manifest or slot and returns a list of problems in the words of modules/schemas/hfs-slots.schema.yaml.
6
6
  import { isPlainObject } from '../../engine/plain-object.mjs';
7
- import { stringList } from '../lib/list.mjs';
7
+ import { stringList, byCodeUnit } from '../lib/list.mjs';
8
8
 
9
9
  export const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
10
10
  export const NAME = /^[a-z][a-z0-9-]*$/;
11
- export const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
11
+ const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
12
12
  /** How a bounded context (a connection) is isolated: its own logical database, or its own schema of a shared one. */
13
13
  const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
14
14
  /** The app kinds that may own a context: the services, never the migrate or cli apps that only run migrations. */
@@ -31,7 +31,7 @@ export function connectionShapeProblems(list, apps) {
31
31
  const keys = (c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && NAME.test(String(c.owner)) && CONTEXT_ISOLATIONS.includes(c.isolation)
32
32
  && (c.provider === undefined || CONNECTION_PROVIDERS.includes(c.provider))
33
33
  && Object.keys(c).every((k) => ['name', 'envPrefix', 'owner', 'isolation', 'provider'].includes(k)) && ['name', 'envPrefix', 'owner', 'isolation'].every((k) => k in c);
34
- if (!list || !list.every(keys)) return [`connections must be a list of {name: kebab-case context name, envPrefix: UPPER_SNAKE prefix of its env keys, owner: the service app that owns the context, isolation: ${CONTEXT_ISOLATIONS.join(' | ')}, provider?: ${CONNECTION_PROVIDERS.join(' | ')}}`];
34
+ if (!list?.every(keys)) return [`connections must be a list of {name: kebab-case context name, envPrefix: UPPER_SNAKE prefix of its env keys, owner: the service app that owns the context, isolation: ${CONTEXT_ISOLATIONS.join(' | ')}, provider?: ${CONNECTION_PROVIDERS.join(' | ')}}`];
35
35
  const owns = (c) => Array.isArray(apps) && apps.some((app) => isPlainObject(app) && app.name === c.owner && CONTEXT_OWNER_KINDS.includes(app.kind));
36
36
  return list.filter((c) => !owns(c)).map((c) => `connections ${c.name} is owned by ${c.owner}, which is not a ${CONTEXT_OWNER_KINDS.join(' or ')} app declared in the same side`);
37
37
  }
@@ -42,7 +42,7 @@ export const TRACKED = ['tracked', 'ignored', 'external'];
42
42
  /** A runtime manifest adds `generated`: tracked, written only by the slot's generatedBy, judged by drift (RT_GENERATED_DRIFT). */
43
43
  const RUNTIME_TRACKED = [...TRACKED, 'generated'];
44
44
  export const TESTS = ['unit-beside', 'e2e', 'none'];
45
- /** The coverage mode of a tracked slot of the be profile (scripts/hfs/coverage-scope.mjs derives the three coverage consumers from it). */
45
+ /** The coverage mode of a tracked slot of the be profile or of a runtime manifest (scripts/hfs/coverage-scope.mjs and scripts/hfs/runtime-coverage-scope.mjs derive the coverage consumers from it). */
46
46
  export const COVERAGE = ['required', 'none'];
47
47
  export const APP_KIND = 'app';
48
48
 
@@ -86,31 +86,54 @@ 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', 'retiredIn', 'successor', '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', 'anonymousActions'];
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
91
  const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances'];
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
- const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor', 'generatedBy', 'parent'];
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
+
95
+ const isPositiveInt = (v) => Number.isInteger(v) && v >= 1;
96
+ const instancesShapeOk = (v) => isPlainObject(v) && Object.values(v).every((list) => strList(list) && list.length);
97
+
98
+ const generatedByInvalid = (generatedBy) => typeof generatedBy !== 'string' || !generatedBy || generatedBy.startsWith('/') || generatedBy.includes('..');
99
+
100
+ const profilesInvalid = (profiles, appScope, scopes) => !Array.isArray(profiles) || !profiles.length || !profiles.every((p) => scopes.includes(p)) || new Set(profiles).size !== profiles.length || (profiles.includes(appScope) && profiles.length !== 1);
101
+
102
+ function runtimeIdentityProblems(slot, at) {
103
+ const bad = [];
104
+ if (!RUNTIME_SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like runtime.<name>`);
105
+ if (JSON.stringify(slot.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push(`${at}: profiles must be [runtime]`);
106
+ if (slot.tracked === 'generated' && generatedByInvalid(slot.generatedBy)) bad.push(`${at}: a generated slot names its generator (generatedBy, a repository-relative path)`);
107
+ if (slot.generatedBy !== undefined && slot.tracked !== 'generated') bad.push(`${at}: generatedBy belongs to a generated slot`);
108
+ return bad;
109
+ }
110
+
111
+ function appIdentityProblems(slot, at, appScope, scopes) {
112
+ const bad = [];
113
+ if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
114
+ if (profilesInvalid(slot.profiles, appScope, scopes)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
115
+ else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
116
+ return bad;
117
+ }
94
118
 
95
119
  /** Shape problems of one slot of a manifest of `kind` (app or runtime). */
96
- export function slotProblems(slot, index, kind, { appScope = 'app', scopes = [] } = {}) {
120
+ function slotIdentityProblems(slot, runtime, slotKeys, at, appScope, scopes) {
97
121
  const bad = [];
98
- const runtime = kind === RUNTIME_KIND;
99
- const slotKeys = new Set(runtime ? RUNTIME_SLOT_KEYS : APP_SLOT_KEYS);
100
- const tracked = runtime ? RUNTIME_TRACKED : TRACKED;
101
- const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
102
- if (!isPlainObject(slot)) return [`${at} is not a map`];
103
122
  for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
104
- if (runtime) {
105
- if (!RUNTIME_SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like runtime.<name>`);
106
- if (JSON.stringify(slot.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push(`${at}: profiles must be [runtime]`);
107
- if (slot.tracked === 'generated' && (typeof slot.generatedBy !== 'string' || !slot.generatedBy || slot.generatedBy.startsWith('/') || slot.generatedBy.includes('..'))) bad.push(`${at}: a generated slot names its generator (generatedBy, a repository-relative path)`);
108
- if (slot.generatedBy !== undefined && slot.tracked !== 'generated') bad.push(`${at}: generatedBy belongs to a generated slot`);
109
- } else {
110
- if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
111
- if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) => scopes.includes(p)) || new Set(slot.profiles).size !== slot.profiles.length || (slot.profiles.includes(appScope) && slot.profiles.length !== 1)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
112
- else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
113
- }
123
+ bad.push(...(runtime ? runtimeIdentityProblems(slot, at) : appIdentityProblems(slot, at, appScope, scopes)));
124
+ return bad;
125
+ }
126
+
127
+ function slotCoverageProblems(slot, at, runtime) {
128
+ const bad = [];
129
+ const measured = slot.tracked === 'tracked' && (runtime || (Array.isArray(slot.profiles) && slot.profiles.includes('be')));
130
+ if (measured && !COVERAGE.includes(slot.coverage)) bad.push(`${at}: coverage must be one of ${COVERAGE.join(', ')} (every tracked slot of ${runtime ? 'a runtime manifest' : 'the be profile'} declares it)`);
131
+ if (!measured && slot.coverage !== undefined) bad.push(`${at}: coverage belongs to a tracked slot of ${runtime ? 'a runtime manifest' : 'the be profile'} only`);
132
+ return bad;
133
+ }
134
+
135
+ function slotLocationProblems(slot, at, runtime, tracked) {
136
+ const bad = [];
114
137
  if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
115
138
  if (slot.parent !== undefined && !/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(String(slot.parent))) bad.push(`${at}: parent must be a slot id`);
116
139
  if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
@@ -118,41 +141,70 @@ export function slotProblems(slot, index, kind, { appScope = 'app', scopes = []
118
141
  if (!NAME.test(String(slot.tier))) bad.push(`${at}: tier must be a tier name, none or inherit`);
119
142
  if (!TESTS.includes(slot.tests)) bad.push(`${at}: tests must be one of ${TESTS.join(', ')}`);
120
143
  if (slot.owner !== undefined && typeof slot.owner !== 'boolean') bad.push(`${at}: owner must be a boolean`);
121
- if (!runtime) {
122
- const measured = Array.isArray(slot.profiles) && slot.profiles.includes('be') && slot.tracked === 'tracked';
123
- if (measured && !COVERAGE.includes(slot.coverage)) bad.push(`${at}: coverage must be one of ${COVERAGE.join(', ')} (every tracked slot of the be profile declares it)`);
124
- if (!measured && slot.coverage !== undefined) bad.push(`${at}: coverage belongs to a tracked slot of the be profile only`);
125
- }
126
- if (slot.appKind !== undefined && !NAME.test(String(slot.appKind))) bad.push(`${at}: appKind must be a name`);
127
- if (slot.trigger !== undefined && !NAME.test(String(slot.trigger))) bad.push(`${at}: trigger must be a trigger kind name`);
128
- if (slot.pattern !== undefined && !NAME.test(String(slot.pattern))) bad.push(`${at}: pattern must be a pattern name`);
129
- if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
130
- if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
144
+ bad.push(...slotCoverageProblems(slot, at, runtime));
145
+ return bad;
146
+ }
147
+
148
+ const NAMED_SLOT_FIELDS = [['appKind', 'appKind must be a name'], ['trigger', 'trigger must be a trigger kind name'], ['pattern', 'pattern must be a pattern name']];
149
+
150
+ function slotInstanceProblems(slot, at) {
151
+ const bad = [];
131
152
  if (slot.pattern !== undefined && !/^[a-z][a-z0-9-]*$/.test(String(slot.pattern))) bad.push(`${at}: pattern must be a kebab-case pattern name`);
132
- if (slot.requiredInstances !== undefined && !(isPlainObject(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
153
+ if (slot.requiredInstances !== undefined && !instancesShapeOk(slot.requiredInstances)) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
154
+ return bad;
155
+ }
156
+
157
+ function slotPatternProblems(slot, at) {
158
+ const bad = [];
159
+ for (const [key, what] of NAMED_SLOT_FIELDS) if (slot[key] !== undefined && !NAME.test(String(slot[key]))) bad.push(`${at}: ${what}`);
160
+ if (slot.minInstances !== undefined && !isPositiveInt(slot.minInstances)) bad.push(`${at}: minInstances must be a positive integer`);
161
+ if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
162
+ bad.push(...slotInstanceProblems(slot, at));
163
+ return bad;
164
+ }
165
+
166
+ const kindsInvalid = (slot) => {
167
+ if (slot.kinds === undefined || !strList(slot.kinds)) return false;
168
+ return !slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k));
169
+ };
170
+ const rolesValid = (roles) => isPlainObject(roles) && Object.keys(roles).length && Object.entries(roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/'));
171
+ const composedByValid = (list) => strList(list) && list.length && new Set(list).size === list.length;
172
+ const budgetValid = (budget) => isPlainObject(budget) && Object.keys(budget).length && Object.values(budget).every(isPositiveInt);
173
+
174
+ function slotListProblems(slot, at) {
175
+ const bad = [];
133
176
  for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
134
- if (slot.kinds !== undefined && strList(slot.kinds) && (!slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k)))) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
135
- if (slot.roles !== undefined && !(isPlainObject(slot.roles) && Object.keys(slot.roles).length && Object.entries(slot.roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/')))) bad.push(`${at}: roles must map a role name to a file name`);
136
- if (slot.composedBy !== undefined && !(strList(slot.composedBy) && slot.composedBy.length && new Set(slot.composedBy).size === slot.composedBy.length)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
137
- if (slot.budget !== undefined && !(isPlainObject(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
138
- if (slot.managedBy !== undefined && !NAME.test(String(slot.managedBy))) bad.push(`${at}: managedBy must be a template id`);
139
- if (slot.liteManagedBy !== undefined && !NAME.test(String(slot.liteManagedBy))) bad.push(`${at}: liteManagedBy must be a template id`);
140
- if (!runtime) {
141
- if (slot.editions !== undefined && !(Array.isArray(slot.editions) && slot.editions.length && slot.editions.every((e) => EDITIONS.includes(e)) && new Set(slot.editions).size === slot.editions.length)) bad.push(`${at}: editions must be a non-empty subset of ${EDITIONS.join(', ')}`);
142
- if (slot.litePresence !== undefined && !(PRESENCE.includes(slot.litePresence) || (isPlainObject(slot.litePresence) && Object.keys(slot.litePresence).length && Object.entries(slot.litePresence).every(([side, value]) => ['be', 'fe'].includes(side) && PRESENCE.includes(value))))) bad.push(`${at}: litePresence must be a presence value or map sides to presence values`);
143
- if (slot.provider !== undefined) {
144
- if (!CONNECTION_PROVIDERS.includes(slot.provider)) bad.push(`${at}: provider must be one of ${CONNECTION_PROVIDERS.join(' | ')}`);
145
- else if (slot.presence !== 'opt-in' || slot.appKind !== undefined) bad.push(`${at}: provider belongs to an opt-in slot that is not an app kind (a connection with that provider enables it)`);
146
- }
147
- if (slot.lite !== undefined) {
148
- const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
149
- && (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
150
- && ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
151
- && (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
152
- && (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
153
- if (!ok) bad.push(`${at}: lite may hold only ${LITE_OVERLAY_KEYS.join(', ')}, each shaped as in the slot body`);
154
- }
177
+ if (kindsInvalid(slot)) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
178
+ if (slot.roles !== undefined && !rolesValid(slot.roles)) bad.push(`${at}: roles must map a role name to a file name`);
179
+ if (slot.composedBy !== undefined && !composedByValid(slot.composedBy)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
180
+ if (slot.budget !== undefined && !budgetValid(slot.budget)) bad.push(`${at}: budget must map names to positive integers`);
181
+ for (const key of ['managedBy', 'liteManagedBy']) if (slot[key] !== undefined && !NAME.test(String(slot[key]))) bad.push(`${at}: ${key} must be a template id`);
182
+ return bad;
183
+ }
184
+
185
+ function slotAppOptionProblems(slot, at) {
186
+ const bad = [];
187
+ if (slot.editions !== undefined && !(Array.isArray(slot.editions) && slot.editions.length && slot.editions.every((e) => EDITIONS.includes(e)) && new Set(slot.editions).size === slot.editions.length)) bad.push(`${at}: editions must be a non-empty subset of ${EDITIONS.join(', ')}`);
188
+ if (slot.litePresence !== undefined && !(PRESENCE.includes(slot.litePresence) || (isPlainObject(slot.litePresence) && Object.keys(slot.litePresence).length && Object.entries(slot.litePresence).every(([side, value]) => ['be', 'fe'].includes(side) && PRESENCE.includes(value))))) bad.push(`${at}: litePresence must be a presence value or map sides to presence values`);
189
+ if (slot.provider !== undefined) {
190
+ if (!CONNECTION_PROVIDERS.includes(slot.provider)) bad.push(`${at}: provider must be one of ${CONNECTION_PROVIDERS.join(' | ')}`);
191
+ else if (slot.presence !== 'opt-in' || slot.appKind !== undefined) bad.push(`${at}: provider belongs to an opt-in slot that is not an app kind (a connection with that provider enables it)`);
155
192
  }
193
+ return bad;
194
+ }
195
+
196
+ function slotLiteOverlayProblems(slot, at) {
197
+ if (slot.lite === undefined) return [];
198
+ const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
199
+ && (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
200
+ && ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
201
+ && (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
202
+ && (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
203
+ return ok ? [] : [`${at}: lite may hold only ${LITE_OVERLAY_KEYS.join(', ')}, each shaped as in the slot body`];
204
+ }
205
+
206
+ function slotEntryProblems(slot, at) {
207
+ const bad = [];
156
208
  if (slot.entries !== undefined) {
157
209
  const listed = strList(slot.entries) && slot.entries.length > 0 && new Set(slot.entries).size === slot.entries.length && slot.entries.every(relPath);
158
210
  if (!listed) bad.push(`${at}: entries must be a non-empty list of unique relative file paths`);
@@ -161,33 +213,53 @@ export function slotProblems(slot, index, kind, { appScope = 'app', scopes = []
161
213
  }
162
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`);
163
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
+ return bad;
217
+ }
218
+
219
+ function slotPolicyProblems(slot, at) {
220
+ const bad = [];
164
221
  if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
165
222
  if (slot.perConnection !== undefined && !(slot.id === 'be.persistence' && strList(slot.perConnection) && slot.perConnection.length && new Set(slot.perConnection).size === slot.perConnection.length && slot.perConnection.every((name) => NAME.test(name)))) bad.push(`${at}: perConnection is a unique list of capability names, only on be.persistence (the platform capabilities whose tables exist on every connection that uses them)`);
166
223
  if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
167
- if (slot.retiredIn !== undefined && !(Number.isInteger(slot.retiredIn) && slot.retiredIn >= 2)) bad.push(`${at}: retiredIn must be a major`);
168
- if (slot.retiredIn !== undefined && typeof slot.successor !== 'string') bad.push(`${at}: a retired slot names its successor`);
169
224
  if (slot.tracked === 'external' && (typeof slot.goesTo !== 'string' || slot.presence !== 'forbidden')) bad.push(`${at}: an external slot is forbidden and says where it goes (goesTo)`);
170
225
  if (slot.presence === 'forbidden' && slot.tracked !== 'external') bad.push(`${at}: a forbidden slot is external`);
171
226
  return bad;
172
227
  }
173
228
 
229
+ export function slotProblems(slot, index, kind, { appScope = 'app', scopes = [] } = {}) {
230
+ const runtime = kind === RUNTIME_KIND;
231
+ const slotKeys = new Set(runtime ? RUNTIME_SLOT_KEYS : APP_SLOT_KEYS);
232
+ const tracked = runtime ? RUNTIME_TRACKED : TRACKED;
233
+ const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
234
+ if (!isPlainObject(slot)) return [`${at} is not a map`];
235
+ const bad = slotIdentityProblems(slot, runtime, slotKeys, at, appScope, scopes);
236
+ bad.push(...slotLocationProblems(slot, at, runtime, tracked), ...slotPatternProblems(slot, at), ...slotListProblems(slot, at));
237
+ if (!runtime) bad.push(...slotAppOptionProblems(slot, at), ...slotLiteOverlayProblems(slot, at));
238
+ bad.push(...slotEntryProblems(slot, at), ...slotPolicyProblems(slot, at));
239
+ return bad;
240
+ }
241
+
174
242
  /** The owners of an external call in a runtime manifest: an api system folder (`api/<system>`, `api/*` any system) or the DB tier. */
175
243
  const INFRA_OWNER = /^(?:api\/(?:\*|[a-z][a-z0-9-]*)|engine\/db)$/;
176
- const RUNTIME_PARAM_KEYS = ['fileLines', 'sourceRoots', 'infraOwners', 'baseWriteMembers', 'baseEnvSeams', 'apiContracts', 'sourceName', 'oneOffNames', 'sharedBasenames', 'generated', 'pinned', 'selfChecks'];
244
+ const RUNTIME_PARAM_KEYS = new Set(['fileLines', 'sourceRoots', 'infraOwners', 'baseWriteMembers', 'baseEnvSeams', 'apiContracts', 'sourceName', 'oneOffNames', 'sharedBasenames', 'generated', 'heldSecrets', 'pinned', 'selfChecks']);
177
245
  const relPath = (v) => typeof v === 'string' && v.length > 0 && !v.startsWith('/') && !v.includes('..') && !v.includes('\\');
178
246
 
179
- /** Shape problems of a parsed manifest of kind runtime (knowledge/hfs/runtime-slots.yaml), in the words of modules/schemas/hfs-slots.schema.yaml. */
180
- export function runtimeShapeProblems(m) {
247
+ function runtimeHeaderProblems(m) {
181
248
  const bad = [];
182
249
  const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'profiles', 'tiers', 'ruleParams', 'crossOwner', 'crossSystem', 'slots', 'consumers']);
183
250
  for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
184
251
  if (!/^starci\/runtime-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/runtime-slots@<major>');
185
252
  if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
186
- if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'retire', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major, retire and pins text');
253
+ if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major and pins text');
187
254
  if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
188
255
  if (JSON.stringify(m.trackedValues) !== JSON.stringify(RUNTIME_TRACKED)) bad.push(`trackedValues must be ${RUNTIME_TRACKED.join(', ')}`);
189
256
  if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
190
257
  if (JSON.stringify(m.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push('profiles must be [runtime]');
258
+ return bad;
259
+ }
260
+
261
+ function runtimeSectionsProblems(m) {
262
+ const bad = [];
191
263
  for (const key of ['crossOwner', 'crossSystem']) if (m[key] !== undefined && typeof m[key] !== 'string') bad.push(`${key} must be text`);
192
264
  if (!isPlainObject(m.tiers) || Object.keys(m.tiers).join() !== RUNTIME_KIND) bad.push('tiers must be a map with exactly runtime');
193
265
  else bad.push(...tierMapProblems(RUNTIME_KIND, m.tiers.runtime));
@@ -200,19 +272,39 @@ export function runtimeShapeProblems(m) {
200
272
  return bad;
201
273
  }
202
274
 
275
+ /** Shape problems of a parsed manifest of kind runtime (knowledge/hfs/runtime-slots.yaml), in the words of modules/schemas/hfs-slots.schema.yaml. */
276
+ export function runtimeShapeProblems(m) {
277
+ return [...runtimeHeaderProblems(m), ...runtimeSectionsProblems(m)];
278
+ }
279
+
203
280
  /** Shape problems of ruleParams.runtime. */
204
- function runtimeParamProblems(rp) {
281
+ function runtimeParamKeyProblems(rp) {
205
282
  const bad = [];
206
- for (const key of Object.keys(rp)) if (!RUNTIME_PARAM_KEYS.includes(key)) bad.push(`ruleParams.runtime.${key} is not a runtime parameter`);
283
+ for (const key of Object.keys(rp)) if (!RUNTIME_PARAM_KEYS.has(key)) bad.push(`ruleParams.runtime.${key} is not a runtime parameter`);
207
284
  for (const key of RUNTIME_PARAM_KEYS) if (!(key in rp)) bad.push(`ruleParams.runtime.${key} is missing`);
285
+ return bad;
286
+ }
287
+
288
+ function runtimeParamFoundationProblems(rp) {
289
+ const bad = [];
208
290
  const fl = rp.fileLines;
209
291
  if (!(isPlainObject(fl) && Number.isInteger(fl.soft) && fl.soft >= 1 && typeof fl.hardGrowth === 'boolean' && Object.keys(fl).length === 2)) bad.push('ruleParams.runtime.fileLines must be {soft, hardGrowth}');
210
292
  const owners = rp.infraOwners;
211
293
  const ownerMap = (v) => isPlainObject(v) && Object.entries(v).every(([key, list]) => key && Array.isArray(list) && list.every((o) => INFRA_OWNER.test(String(o))) && new Set(list).size === list.length);
212
- if (!isPlainObject(owners) || Object.keys(owners).sort().join() !== 'globals,modules,programs' || !['globals', 'modules', 'programs'].every((k) => ownerMap(owners[k]))) bad.push('ruleParams.runtime.infraOwners must be {modules, globals, programs}, each mapping a name to unique owners api/<system>, api/* or engine/db ([] means nowhere)');
294
+ if (!isPlainObject(owners) || Object.keys(owners).sort(byCodeUnit).join() !== 'globals,modules,programs' || !['globals', 'modules', 'programs'].every((k) => ownerMap(owners[k]))) bad.push('ruleParams.runtime.infraOwners must be {modules, globals, programs}, each mapping a name to unique owners api/<system>, api/* or engine/db ([] means nowhere)');
295
+ return bad;
296
+ }
297
+
298
+ function runtimeParamListProblems(rp) {
299
+ const bad = [];
213
300
  for (const key of ['sourceRoots', 'baseWriteMembers', 'oneOffNames', 'sharedBasenames']) if (!(strList(rp[key]) && rp[key].length && new Set(rp[key]).size === rp[key].length)) bad.push(`ruleParams.runtime.${key} must be a non-empty list of unique strings`);
214
301
  if (!(Array.isArray(rp.baseEnvSeams) && rp.baseEnvSeams.every(relPath))) bad.push('ruleParams.runtime.baseEnvSeams must be a list of repository-relative paths');
215
302
  if (!(isPlainObject(rp.apiContracts) && Object.entries(rp.apiContracts).every(([system, file]) => NAME.test(system) && relPath(file)))) bad.push('ruleParams.runtime.apiContracts must map an api system to its calls contract path');
303
+ return bad;
304
+ }
305
+
306
+ function runtimeParamArtifactProblems(rp) {
307
+ const bad = [];
216
308
  let sourceNameOk = typeof rp.sourceName === 'string';
217
309
  try { if (sourceNameOk) new RegExp(rp.sourceName); } catch { sourceNameOk = false; }
218
310
  if (!sourceNameOk) bad.push('ruleParams.runtime.sourceName must be a regular expression');
@@ -222,6 +314,15 @@ function runtimeParamProblems(rp) {
222
314
  return bad;
223
315
  }
224
316
 
317
+ function runtimeParamProblems(rp) {
318
+ return [
319
+ ...runtimeParamKeyProblems(rp),
320
+ ...runtimeParamFoundationProblems(rp),
321
+ ...runtimeParamListProblems(rp),
322
+ ...runtimeParamArtifactProblems(rp),
323
+ ];
324
+ }
325
+
225
326
  /** Shape problems of the tier map of one profile. */
226
327
  export function tierMapProblems(profile, tiers) {
227
328
  const bad = [];
@@ -21,6 +21,37 @@ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
21
21
  const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
22
22
  const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
23
23
 
24
+ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
25
+ if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) return null;
26
+ const c = resolver.classifyPath(file);
27
+ if (c.status !== 'owned' || c.tracking === 'ignored') return null;
28
+ const base = path.posix.basename(file);
29
+ if (FREE_NAMES.has(base)) return null;
30
+ const slot = resolver.slot(c.slot);
31
+ if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
32
+ const admitted = allowsFile(resolver, file);
33
+ if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
34
+ if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
35
+ if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
36
+ return { c, base, suffixes, bannedSuffixes };
37
+ }
38
+
39
+ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuffixes) {
40
+ const parts = base.slice(0, -'.ts'.length).split('.');
41
+ const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
42
+ if (banned) {
43
+ const homeNote = BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : '';
44
+ return { code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${homeNote}` };
45
+ }
46
+ if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
47
+ return { code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` };
48
+ }
49
+ if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
50
+ return { code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` };
51
+ }
52
+ return null;
53
+ }
54
+
24
55
  /**
25
56
  * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
26
57
  * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
@@ -38,53 +69,42 @@ function sourceFormFindings({ files, resolver }) {
38
69
  }
39
70
  const findings = [];
40
71
  for (const file of files) {
41
- if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
42
- const c = resolver.classifyPath(file);
43
- if (c.status !== 'owned' || c.tracking === 'ignored') continue;
44
- const base = path.posix.basename(file);
45
- if (FREE_NAMES.has(base)) continue;
46
- // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
47
- const slot = resolver.slot(c.slot);
48
- if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
49
- // So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
50
- const admitted = allowsFile(resolver, file);
51
- if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
52
- // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
53
- if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
54
- if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
55
- const parts = base.slice(0, -'.ts'.length).split('.');
56
- const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
57
- if (banned) {
58
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
59
- } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
60
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
61
- } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
62
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
63
- }
72
+ const context = sourceFormContext(file, resolver, suffixes, bannedSuffixes);
73
+ if (!context) continue;
74
+ const finding = sourceFormFinding(file, context, boundSuffixes);
75
+ if (finding) findings.push(finding);
64
76
  }
65
77
  return findings;
66
78
  }
67
79
 
80
+ function forbiddenPathFinding(file, c, resolver) {
81
+ const slot = resolver.slot(c.slot);
82
+ if (slotOwnsSecrets(slot)) return null;
83
+ const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
84
+ const goesToNote = c.goesTo ? `; it belongs at ${c.goesTo}` : '';
85
+ return { code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${goesToNote}` };
86
+ }
87
+
88
+ function pathStatusFinding(file, resolver) {
89
+ const c = resolver.classifyPath(file);
90
+ if (c.status === 'no-slot') {
91
+ const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
92
+ return { code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${nearestNote}` };
93
+ }
94
+ if (c.status === 'ambiguous') return { code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` };
95
+ if (c.status === 'not-enabled') return { code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` };
96
+ if (c.status === 'forbidden') return forbiddenPathFinding(file, c, resolver);
97
+ if (c.tracking === 'ignored') return { code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` };
98
+ return null;
99
+ }
100
+
68
101
  /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
69
102
  export function pathFindings({ files, resolver, profile }) {
70
103
  const findings = [];
71
104
  for (const file of files) {
72
105
  if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
73
- const c = resolver.classifyPath(file);
74
- if (c.status === 'no-slot') {
75
- findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
76
- } else if (c.status === 'ambiguous') {
77
- findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
78
- } else if (c.status === 'not-enabled') {
79
- findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
80
- } else if (c.status === 'forbidden') {
81
- const slot = resolver.slot(c.slot);
82
- if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
83
- const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
84
- findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
85
- } else if (c.tracking === 'ignored') {
86
- findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
87
- }
106
+ const finding = pathStatusFinding(file, resolver);
107
+ if (finding) findings.push(finding);
88
108
  }
89
109
 
90
110
  if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { APP_SCOPE } from './slots.mjs';
4
+
5
+ const DEP_SECTIONS = ['dependencies', 'devDependencies'];
6
+ const pinnedSpec = (spec, pin) => (spec === pin.version ? null : `declared ${spec}, pinned ${pin.version}`);
7
+
8
+ function pinFileFindings(repoRoot, file, profile, pins) {
9
+ const findings = [];
10
+ let pkg;
11
+ try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { return findings; }
12
+ for (const [name, pin] of Object.entries(pins)) {
13
+ // The app root's one package.json carries the pins of both sides.
14
+ if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
15
+ for (const section of DEP_SECTIONS) {
16
+ const spec = pkg[section]?.[name];
17
+ if (spec === undefined) continue;
18
+ const drift = pinnedSpec(spec, pin);
19
+ if (drift) findings.push({ code: 'HFS_CANON_PIN_DRIFT', level: 'error', path: file, dependency: name, section, pinned: pin.version, declared: spec, message: `${name} in ${file} ${section}: ${drift}` });
20
+ }
21
+ }
22
+ return findings;
23
+ }
24
+
25
+ export function pinFindings({ repoRoot, files, profile, pins, only }) {
26
+ const findings = [];
27
+ for (const file of files.filter((f) => (f === 'package.json' || f.endsWith('/package.json')) && (!only || only.has(f)))) {
28
+ findings.push(...pinFileFindings(repoRoot, file, profile, pins));
29
+ }
30
+ return findings;
31
+ }
@@ -6,19 +6,36 @@ import path from 'node:path';
6
6
  import { revParseQuery } from '../api/git/rev-parse-query.mjs';
7
7
  import { remote as gitRemote } from '../api/git/remote.mjs';
8
8
  import { gitOutputOf } from '../lib/git.mjs';
9
+ import { trimTrailingSlashes } from './trailing-slashes.mjs';
9
10
 
10
11
  /** One git call (a scripts/api/git call file) in `root`: its trimmed stdout; throws unless git exits 0. */
11
12
  const git = (call, root, args) => gitOutputOf(call(args, { cwd: root })).trim();
12
13
  const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
14
+ const GIT_SUFFIX_SOURCE = String.raw`\.git$`;
15
+ const GIT_SUFFIX = new RegExp(GIT_SUFFIX_SOURCE, 'iu');
16
+
17
+ // The identity of a folder that has its own .git entry holds while that entry is the same file system object (same inode, same modification time), so a
18
+ // process asks Git once per such folder state instead of at every check. A folder with no .git entry of its own is asked every time.
19
+ const known = new Map();
20
+ const stateOf = (root) => { try { const stat = fs.statSync(path.join(root, '.git')); return `${real(root)}|${stat.ino}|${stat.mtimeMs}`; } catch { return null; } };
13
21
 
14
22
  /** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
15
23
  function identityOf(root) {
24
+ const state = stateOf(root);
25
+ if (state === null) return askGit(root);
26
+ if (!known.has(state)) known.set(state, askGit(root));
27
+ return known.get(state);
28
+ }
29
+
30
+ function askGit(root) {
16
31
  let inWorkTree = false, repositoryRoot = false, home = null;
17
32
  try {
18
33
  // Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
19
- repositoryRoot = real(git(revParseQuery, root, ['--show-toplevel'])) === real(root);
34
+ // One git call answers both questions: the top level on the first line, the common git dir on the second.
35
+ const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
36
+ repositoryRoot = real(top) === real(root);
20
37
  inWorkTree = true;
21
- const common = path.resolve(root, git(revParseQuery, root, ['--git-common-dir']));
38
+ const common = path.resolve(root, commonDir);
22
39
  if (repositoryRoot && path.basename(common) === '.git') home = path.dirname(common);
23
40
  } catch { /* Not a Git work tree; the caller falls back to the folder and package name. */ }
24
41
  return { inWorkTree, repositoryRoot, home };
@@ -34,8 +51,8 @@ export function repositoryName(root) {
34
51
  if (home) return path.basename(home);
35
52
  if (repositoryRoot) {
36
53
  try {
37
- const remote = git(gitRemote, root, ['get-url', 'origin']).replace(/[\/]+$/u, '').replace(/\.git$/iu, '');
38
- const name = remote.split(/[\/:]/u).pop();
54
+ const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'])).replace(GIT_SUFFIX, '');
55
+ const name = remote.split(/[/:]/u).pop();
39
56
  if (name) return name;
40
57
  } catch { /* No origin remote. */ }
41
58
  }