@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,525 @@
1
+ // Patch operations: apply add/replace/remove changes to a feature-file's
2
+ // SourceFile in-place, working at the r.*-call granularity. Custom code
3
+ // (helpers, comments, imports, anything between calls) survives every
4
+ // patch unchanged — the patcher only touches the spans it owns.
5
+ //
6
+ // **Identity model — Natural-Key:** patterns are addressed by the
7
+ // human-readable name they carry: entity-name, handler-name, nav-id,
8
+ // hook-target+type, etc. Reorders and re-renderings don't break IDs;
9
+ // renames are explicit (remove old → add new). For the few singleton
10
+ // patterns (toggleable, requires, systemScope) the kind itself is the
11
+ // key — a feature has at most one of each.
12
+ //
13
+ // **Position semantics:**
14
+ // - addPattern → appended at the end of the setup callback
15
+ // - replacePattern → in place, same indentation as the original call
16
+ // - removePattern → call + leading blank-line whitespace gone
17
+ //
18
+ // **Renderer-driven output.** Every pattern lands in canonical Object-
19
+ // Form (single-arg literal, see render.ts). Existing patterns in legacy
20
+ // positional form get converted on replace; new patterns start
21
+ // canonical. Schema-Version-Header is the renderer's responsibility.
22
+ //
23
+ // **Comment-Preservation — known limitation.** Inline comments INSIDE a
24
+ // pattern (e.g. `// reason: legacy field` on an entity field property)
25
+ // are LOST on replace, because the renderer regenerates the call from
26
+ // the parsed FeaturePattern, which doesn't carry comment-trivia.
27
+ // Comments BETWEEN patterns (helper-functions, top-of-feature notes,
28
+ // imports) survive every patch — only comments authored on lines the
29
+ // patcher rewrites are dropped. Tracked as a future-work item: see
30
+ // roadmap C-Notes for the canonical-comment-attach Pattern that would
31
+ // preserve prefixed `// kumiko-comment:` markers across roundtrips.
32
+
33
+ import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
34
+ import type { FeaturePattern, FeaturePatternKind } from "./patterns";
35
+ import { indent, PATTERN_INDENT, renderPattern } from "./render";
36
+
37
+ // =============================================================================
38
+ // PatternId — natural-key per pattern kind
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Identifier used by replace/remove. Discriminated union: each pattern
43
+ * kind names the property the patcher must match against. Adding a new
44
+ * pattern kind requires a new entry here so the type system forces the
45
+ * call-site to think about identity (or fall through to "first call of
46
+ * this kind" via the singleton helpers below).
47
+ */
48
+ export type PatternId =
49
+ | { readonly kind: "entity"; readonly entityName: string }
50
+ | { readonly kind: "relation"; readonly entityName: string; readonly relationName: string }
51
+ | { readonly kind: "nav"; readonly id: string }
52
+ | { readonly kind: "workspace"; readonly id: string }
53
+ | { readonly kind: "screen"; readonly id: string }
54
+ | { readonly kind: "writeHandler"; readonly handlerName: string }
55
+ | { readonly kind: "queryHandler"; readonly handlerName: string }
56
+ | { readonly kind: "hook"; readonly hookType: string; readonly target: string }
57
+ | { readonly kind: "entityHook"; readonly hookType: string; readonly entityName: string }
58
+ | { readonly kind: "metric"; readonly shortName: string }
59
+ | { readonly kind: "secret"; readonly shortName: string }
60
+ | { readonly kind: "claimKey"; readonly shortName: string }
61
+ | { readonly kind: "referenceData"; readonly entityName: string }
62
+ | { readonly kind: "useExtension"; readonly extensionName: string; readonly entityName: string }
63
+ | { readonly kind: "job"; readonly jobName: string }
64
+ | { readonly kind: "notification"; readonly notificationName: string }
65
+ | { readonly kind: "httpRoute"; readonly method: string; readonly path: string }
66
+ | { readonly kind: "projection"; readonly name: string }
67
+ | { readonly kind: "multiStreamProjection"; readonly name: string }
68
+ | { readonly kind: "defineEvent"; readonly eventName: string }
69
+ | {
70
+ readonly kind: "eventMigration";
71
+ readonly eventName: string;
72
+ readonly fromVersion: number;
73
+ readonly toVersion: number;
74
+ }
75
+ | { readonly kind: "extendsRegistrar"; readonly extensionName: string }
76
+ // Singleton patterns — only one per feature, kind alone identifies them.
77
+ | { readonly kind: "requires" }
78
+ | { readonly kind: "optionalRequires" }
79
+ | { readonly kind: "readsConfig" }
80
+ | { readonly kind: "systemScope" }
81
+ | { readonly kind: "toggleable" }
82
+ | { readonly kind: "config" }
83
+ | { readonly kind: "translations" }
84
+ | { readonly kind: "authClaims" };
85
+
86
+ // =============================================================================
87
+ // Change ops — generic apply API
88
+ // =============================================================================
89
+
90
+ export type PatternChange =
91
+ | { readonly op: "add"; readonly pattern: FeaturePattern }
92
+ | { readonly op: "replace"; readonly id: PatternId; readonly pattern: FeaturePattern }
93
+ | { readonly op: "remove"; readonly id: PatternId };
94
+
95
+ /**
96
+ * Apply a sequence of changes to the source file in-place. The list is
97
+ * processed in order; replace/remove failures (id not found) throw so
98
+ * callers can react explicitly — silent no-ops would mask design bugs
99
+ * in the Designer/AI generator. Adds always succeed.
100
+ *
101
+ * The function does NOT save the file — `sourceFile.saveSync()` (or the
102
+ * caller's persistence layer) is expected to follow.
103
+ */
104
+ export function applyChanges(sourceFile: SourceFile, changes: readonly PatternChange[]): void {
105
+ for (const change of changes) {
106
+ switch (change.op) {
107
+ case "add":
108
+ addPattern(sourceFile, change.pattern);
109
+ break;
110
+ case "replace":
111
+ replacePattern(sourceFile, change.id, change.pattern);
112
+ break;
113
+ case "remove":
114
+ removePattern(sourceFile, change.id);
115
+ break;
116
+ default: {
117
+ const _exhaustive: never = change;
118
+ throw new Error(`applyChanges: unknown op ${String(_exhaustive)}`);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // =============================================================================
125
+ // Add
126
+ // =============================================================================
127
+
128
+ /**
129
+ * Low-level escape hatch: append a hand-built FeaturePattern at the end
130
+ * of the setup callback's body. **Prefer the typed `add{Kind}` methods
131
+ * on `createFeaturePatcher(sf)`** — they take natural args, build the
132
+ * FeaturePattern internally, and avoid SourceLocation boilerplate.
133
+ *
134
+ * Use this directly when:
135
+ * - Migrating a parsed pattern from another file (already-built
136
+ * FeaturePattern object on hand)
137
+ * - The pattern kind isn't yet covered by a typed `add{Kind}`
138
+ *
139
+ * The pattern is rendered (canonical Object-Form) and inserted as the
140
+ * last statement, separated from the previous one by a blank line —
141
+ * biome-stable formatting that matches the renderFeatureFile output.
142
+ */
143
+ export function addPattern(sourceFile: SourceFile, pattern: FeaturePattern): void {
144
+ const setup = findSetupCallback(sourceFile);
145
+ if (!setup) {
146
+ throw new Error("addPattern: no defineFeature(name, (r) => { ... }) call found");
147
+ }
148
+ const body = setup.body;
149
+ const rendered = indent(renderPattern(pattern), PATTERN_INDENT);
150
+
151
+ // Find the closing brace of the body to insert just before it. The body
152
+ // is a Block; its last child is the close-brace, so the safe insertion
153
+ // point is the position of the close-brace (insertText pushes it down).
154
+ const closeBracePos = body.getEnd() - 1; // `}`
155
+ const lastStatement = lastNonTriviaChild(body);
156
+ // If the body has at least one statement, prefix with a blank line so
157
+ // every pattern is visually separated. For an empty setup callback,
158
+ // skip the leading newline so the first statement isn't preceded by a
159
+ // gratuitous blank line.
160
+ const needsLeadingBlank = lastStatement !== undefined;
161
+ const text = needsLeadingBlank ? `\n${rendered}\n` : `${rendered}\n`;
162
+ sourceFile.insertText(closeBracePos, text);
163
+ }
164
+
165
+ // =============================================================================
166
+ // Replace
167
+ // =============================================================================
168
+
169
+ /**
170
+ * Find the call matching `id` and replace the entire CallExpression text
171
+ * with the rendered version of `pattern`. The replacement is reindented
172
+ * to match the original call's column so existing helpers/comments
173
+ * around it stay aligned. Throws when no call matches — callers must
174
+ * handle that case explicitly.
175
+ */
176
+ export function replacePattern(
177
+ sourceFile: SourceFile,
178
+ id: PatternId,
179
+ pattern: FeaturePattern,
180
+ ): void {
181
+ const call = findCallForId(sourceFile, id);
182
+ if (!call) {
183
+ throw new Error(`replacePattern: no call found for ${describeId(id)}`);
184
+ }
185
+
186
+ // Whole call-statement spans from the CallExpression's start through
187
+ // its enclosing ExpressionStatement (which carries the trailing `;`).
188
+ const enclosingStatement = call.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
189
+ const startNode = enclosingStatement ?? call;
190
+
191
+ const startPos = startNode.getStart();
192
+ const endPos = startNode.getEnd();
193
+
194
+ // Detect column of the original call's first non-whitespace character;
195
+ // the rendered pattern starts at column 0 and gets indented to match.
196
+ const startLineCol = sourceFile.getLineAndColumnAtPos(startPos);
197
+ const originalIndent = " ".repeat(Math.max(0, startLineCol.column - 1));
198
+ const rendered = indent(renderPattern(pattern), originalIndent).trimStart();
199
+
200
+ sourceFile.replaceText([startPos, endPos], rendered);
201
+ }
202
+
203
+ // =============================================================================
204
+ // Remove
205
+ // =============================================================================
206
+
207
+ /**
208
+ * Find the call matching `id` and remove it together with its trailing
209
+ * newline. Comments belonging to the pattern are unaffected only when
210
+ * they live BEFORE the call as line-leading trivia — those leading
211
+ * comments are kept (they may belong to surrounding code, the patcher
212
+ * can't disambiguate without semantic markers). Inline comments on the
213
+ * same line as the call are removed with the call.
214
+ */
215
+ export function removePattern(sourceFile: SourceFile, id: PatternId): void {
216
+ const call = findCallForId(sourceFile, id);
217
+ if (!call) {
218
+ throw new Error(`removePattern: no call found for ${describeId(id)}`);
219
+ }
220
+ const enclosingStatement = call.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
221
+ const target = enclosingStatement ?? call;
222
+
223
+ // Erase from the start of the line containing the statement (so leading
224
+ // indentation goes with it) through the trailing newline, including the
225
+ // *leading* blank line that addPattern emits — keeps blank-line counts
226
+ // stable under add → remove cycles. We don't touch leading comments.
227
+ const startPos = lineStart(sourceFile, target.getStart());
228
+ const endPos = lineEnd(sourceFile, target.getEnd());
229
+
230
+ // Collapse a preceding blank line if there is one (avoids a double
231
+ // blank line between the now-adjacent statements).
232
+ const collapseStart = collapsePrecedingBlankLine(sourceFile, startPos);
233
+ sourceFile.replaceText([collapseStart, endPos + 1], "");
234
+ }
235
+
236
+ // =============================================================================
237
+ // Lookup
238
+ // =============================================================================
239
+
240
+ function findSetupCallback(
241
+ sourceFile: SourceFile,
242
+ ): { call: CallExpression; body: Node } | undefined {
243
+ for (const stmt of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
244
+ if (stmt.getExpression().getText() !== "defineFeature") continue;
245
+ const setupArg = stmt.getArguments()[1];
246
+ if (!setupArg) continue;
247
+ const arrow = setupArg.asKind(SyntaxKind.ArrowFunction);
248
+ if (!arrow) continue;
249
+ return { call: stmt, body: arrow.getBody() };
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ /**
255
+ * Singleton kinds: a feature has at most one of each. The Boot-Validator
256
+ * rejects features that declare two `r.requires(...)` etc. — the patcher
257
+ * asserts the same invariant so a corrupt source file produces an
258
+ * explicit error here, not a silent first-match win.
259
+ *
260
+ * Exported so the pattern-library tests + downstream consumers
261
+ * (Designer, AI-Builder) share one source-of-truth — duplicating this
262
+ * set would let the library's `singleton: true` flags drift silently
263
+ * from the patcher's enforcement.
264
+ */
265
+ export const SINGLETON_KINDS: ReadonlySet<PatternId["kind"]> = new Set([
266
+ "requires",
267
+ "optionalRequires",
268
+ "readsConfig",
269
+ "systemScope",
270
+ "toggleable",
271
+ "config",
272
+ "translations",
273
+ "authClaims",
274
+ ]);
275
+
276
+ /**
277
+ * Return the CallExpression in the setup callback whose call shape
278
+ * matches the given id. Reads the call arguments structurally — same
279
+ * paths the parser walks, no re-parsing through extractors.ts (would
280
+ * be redundant work).
281
+ *
282
+ * For singleton kinds (requires, toggleable, etc.) the patcher
283
+ * additionally asserts that the file contains AT MOST one matching
284
+ * call. Two calls of the same singleton kind would let the first-match
285
+ * silently win; we'd rather throw so Designer/AI surfacing the corrupt
286
+ * feature can fix it explicitly.
287
+ */
288
+ function findCallForId(sourceFile: SourceFile, id: PatternId): CallExpression | undefined {
289
+ const setup = findSetupCallback(sourceFile);
290
+ if (!setup) return undefined;
291
+ const registrarParam = setup.call
292
+ .getArguments()[1]
293
+ ?.asKind(SyntaxKind.ArrowFunction)
294
+ ?.getParameters()[0]
295
+ ?.getName();
296
+ if (!registrarParam) return undefined;
297
+
298
+ const matches: CallExpression[] = [];
299
+ for (const call of setup.body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
300
+ const propAccess = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
301
+ if (!propAccess) continue;
302
+ if (propAccess.getExpression().getText() !== registrarParam) continue;
303
+ if (propAccess.getName() !== id.kind) continue;
304
+ if (callMatchesId(call, id)) matches.push(call);
305
+ }
306
+
307
+ if (SINGLETON_KINDS.has(id.kind) && matches.length > 1) {
308
+ throw new Error(
309
+ `findCallForId: ${id.kind} is a singleton but ${matches.length} calls were found — feature file is corrupt`,
310
+ );
311
+ }
312
+ return matches[0];
313
+ }
314
+
315
+ function callMatchesId(call: CallExpression, id: PatternId): boolean {
316
+ switch (id.kind) {
317
+ // Singletons: kind alone identifies the call.
318
+ case "requires":
319
+ case "optionalRequires":
320
+ case "readsConfig":
321
+ case "systemScope":
322
+ case "toggleable":
323
+ case "config":
324
+ case "translations":
325
+ case "authClaims":
326
+ return true;
327
+
328
+ case "entity":
329
+ return (
330
+ matchFirstArgString(call, id.entityName) || matchObjectProperty(call, "name", id.entityName)
331
+ );
332
+ case "relation":
333
+ // Positional: r.relation(entity, name, def) | Object: { entity, name, ... }
334
+ if (matchFirstArgString(call, id.entityName)) {
335
+ const second = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
336
+ return second === id.relationName;
337
+ }
338
+ return (
339
+ matchObjectProperty(call, "entity", id.entityName) &&
340
+ matchObjectProperty(call, "name", id.relationName)
341
+ );
342
+ case "nav":
343
+ case "workspace":
344
+ case "screen":
345
+ return matchObjectProperty(call, "id", id.id);
346
+ case "writeHandler":
347
+ case "queryHandler":
348
+ return (
349
+ matchFirstArgString(call, id.handlerName) ||
350
+ matchObjectProperty(call, "name", id.handlerName)
351
+ );
352
+ case "hook":
353
+ // Positional: r.hook(type, target, fn) | Object: { type, target }
354
+ if (matchFirstArgString(call, id.hookType)) {
355
+ const target = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
356
+ return target === id.target;
357
+ }
358
+ return (
359
+ matchObjectProperty(call, "type", id.hookType) &&
360
+ matchObjectProperty(call, "target", id.target)
361
+ );
362
+ case "entityHook":
363
+ if (matchFirstArgString(call, id.hookType)) {
364
+ const ent = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
365
+ return ent === id.entityName;
366
+ }
367
+ return (
368
+ matchObjectProperty(call, "type", id.hookType) &&
369
+ matchObjectProperty(call, "entity", id.entityName)
370
+ );
371
+ case "metric":
372
+ case "secret":
373
+ case "claimKey":
374
+ return (
375
+ matchFirstArgString(call, id.shortName) || matchObjectProperty(call, "name", id.shortName)
376
+ );
377
+ case "referenceData":
378
+ return (
379
+ matchFirstArgString(call, id.entityName) ||
380
+ matchObjectProperty(call, "entity", id.entityName)
381
+ );
382
+ case "useExtension":
383
+ // Positional: r.useExtension(name, entity) | Object: { name, entity }
384
+ if (matchFirstArgString(call, id.extensionName)) {
385
+ const ent = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
386
+ return ent === id.entityName;
387
+ }
388
+ return (
389
+ matchObjectProperty(call, "name", id.extensionName) &&
390
+ matchObjectProperty(call, "entity", id.entityName)
391
+ );
392
+ case "job":
393
+ return matchFirstArgString(call, id.jobName) || matchObjectProperty(call, "name", id.jobName);
394
+ case "notification":
395
+ return (
396
+ matchFirstArgString(call, id.notificationName) ||
397
+ matchObjectProperty(call, "name", id.notificationName)
398
+ );
399
+ case "httpRoute":
400
+ // Object form only; positional doesn't apply.
401
+ return (
402
+ matchObjectProperty(call, "method", id.method) && matchObjectProperty(call, "path", id.path)
403
+ );
404
+ case "projection":
405
+ case "multiStreamProjection":
406
+ return matchObjectProperty(call, "name", id.name);
407
+ case "defineEvent":
408
+ return (
409
+ matchFirstArgString(call, id.eventName) || matchObjectProperty(call, "name", id.eventName)
410
+ );
411
+ case "eventMigration": {
412
+ // Positional: r.eventMigration(name, from, to, fn)
413
+ if (matchFirstArgString(call, id.eventName)) {
414
+ const from = numericArg(call, 1);
415
+ const to = numericArg(call, 2);
416
+ return from === id.fromVersion && to === id.toVersion;
417
+ }
418
+ // Object: { event, fromVersion, toVersion }
419
+ return (
420
+ matchObjectProperty(call, "event", id.eventName) &&
421
+ matchObjectNumericProperty(call, "fromVersion", id.fromVersion) &&
422
+ matchObjectNumericProperty(call, "toVersion", id.toVersion)
423
+ );
424
+ }
425
+ case "extendsRegistrar":
426
+ return matchFirstArgString(call, id.extensionName);
427
+ default: {
428
+ const _exhaustive: never = id;
429
+ return _exhaustive;
430
+ }
431
+ }
432
+ }
433
+
434
+ function matchFirstArgString(call: CallExpression, expected: string): boolean {
435
+ const first = call.getArguments()[0];
436
+ const lit = first?.asKind(SyntaxKind.StringLiteral);
437
+ return lit?.getLiteralValue() === expected;
438
+ }
439
+
440
+ function matchObjectProperty(call: CallExpression, propName: string, expected: string): boolean {
441
+ const obj = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
442
+ if (!obj) return false;
443
+ const init = obj
444
+ .getProperty(propName)
445
+ ?.asKind(SyntaxKind.PropertyAssignment)
446
+ ?.getInitializer()
447
+ ?.asKind(SyntaxKind.StringLiteral);
448
+ return init?.getLiteralValue() === expected;
449
+ }
450
+
451
+ function matchObjectNumericProperty(
452
+ call: CallExpression,
453
+ propName: string,
454
+ expected: number,
455
+ ): boolean {
456
+ const obj = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
457
+ if (!obj) return false;
458
+ const init = obj
459
+ .getProperty(propName)
460
+ ?.asKind(SyntaxKind.PropertyAssignment)
461
+ ?.getInitializer()
462
+ ?.asKind(SyntaxKind.NumericLiteral);
463
+ return init !== undefined && Number(init.getText()) === expected;
464
+ }
465
+
466
+ function numericArg(call: CallExpression, idx: number): number | undefined {
467
+ const lit = call.getArguments()[idx]?.asKind(SyntaxKind.NumericLiteral);
468
+ if (!lit) return undefined;
469
+ return Number(lit.getText());
470
+ }
471
+
472
+ // =============================================================================
473
+ // Format helpers — line boundaries / blank-line collapse
474
+ // (indent / PATTERN_INDENT live in render.ts and are imported above.)
475
+ // =============================================================================
476
+
477
+ function lastNonTriviaChild(body: Node): Node | undefined {
478
+ // Block nodes have child[0] = `{`, last = `}`. Find the last
479
+ // SyntaxList element that's an actual statement — that signals
480
+ // whether the body is empty for blank-line decisions.
481
+ if (!body.isKind(SyntaxKind.Block)) return undefined;
482
+ const statements = body.getStatements();
483
+ return statements[statements.length - 1];
484
+ }
485
+
486
+ function lineStart(sourceFile: SourceFile, pos: number): number {
487
+ const text = sourceFile.getFullText();
488
+ let i = pos;
489
+ while (i > 0 && text[i - 1] !== "\n") i--;
490
+ return i;
491
+ }
492
+
493
+ function lineEnd(sourceFile: SourceFile, pos: number): number {
494
+ const text = sourceFile.getFullText();
495
+ let i = pos;
496
+ while (i < text.length && text[i] !== "\n") i++;
497
+ return i;
498
+ }
499
+
500
+ function collapsePrecedingBlankLine(sourceFile: SourceFile, startPos: number): number {
501
+ // If the line preceding `startPos` is empty (only whitespace), include
502
+ // it in the deletion range so add → remove leaves a clean file.
503
+ const text = sourceFile.getFullText();
504
+ if (startPos < 2) return startPos;
505
+ const i = startPos - 1; // \n at end of previous line
506
+ if (text[i] !== "\n") return startPos;
507
+ let j = i - 1;
508
+ while (j >= 0 && text[j] !== "\n" && (text[j] === " " || text[j] === "\t")) j--;
509
+ if (j < 0 || text[j] === "\n") {
510
+ // Found an empty (whitespace-only) preceding line — include its
511
+ // newline in the deletion span.
512
+ return j + 1;
513
+ }
514
+ return startPos;
515
+ }
516
+
517
+ // Used only in error messages — stringifies kind + identifying fields
518
+ // in a `kind(field=value, ...)` shape for at-a-glance debugging.
519
+ function describeId(id: PatternId): string {
520
+ const fields = Object.entries(id as Readonly<Record<string, unknown>>)
521
+ .filter(([key]) => key !== "kind")
522
+ .map(([key, value]) => `${key}=${String(value)}`)
523
+ .join(", ");
524
+ return `${id.kind as FeaturePatternKind}(${fields})`;
525
+ }