@michaelthielemann/kestrel 4.1.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +274 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +157 -0
  15. package/dist/defineConfig.js +41 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/cron.d.ts +10 -0
  43. package/dist/triggers/cron.js +44 -0
  44. package/dist/triggers/cronMatch.d.ts +10 -0
  45. package/dist/triggers/cronMatch.js +68 -0
  46. package/dist/triggers/http.d.ts +58 -0
  47. package/dist/triggers/http.js +292 -0
  48. package/package.json +25 -151
  49. package/NOTICE +0 -12
  50. package/kestrel.config.ts +0 -28
  51. package/layers/access/nuxt.config.ts +0 -5
  52. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  53. package/layers/access/server/middleware/access-guard.ts +0 -32
  54. package/layers/access/server/types.d.ts +0 -7
  55. package/layers/admin/app/components/AdminAccount.vue +0 -212
  56. package/layers/admin/app/components/AdminNav.vue +0 -44
  57. package/layers/admin/app/components/BlockFields.vue +0 -67
  58. package/layers/admin/app/components/BlockPreview.vue +0 -413
  59. package/layers/admin/app/components/BlockTree.vue +0 -433
  60. package/layers/admin/app/components/BlocksBody.vue +0 -163
  61. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  62. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  63. package/layers/admin/app/components/CollectionList.vue +0 -429
  64. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  65. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  66. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  67. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  68. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  69. package/layers/admin/app/components/EditorStatus.vue +0 -211
  70. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  71. package/layers/admin/app/components/FieldsBody.vue +0 -23
  72. package/layers/admin/app/components/LocaleBar.vue +0 -107
  73. package/layers/admin/app/components/PageFields.vue +0 -129
  74. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  75. package/layers/admin/app/components/SeoFields.vue +0 -210
  76. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  77. package/layers/admin/app/composables/useAuth.ts +0 -68
  78. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  79. package/layers/admin/app/composables/useBlocks.ts +0 -7
  80. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  81. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  82. package/layers/admin/app/composables/useCollections.ts +0 -6
  83. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  84. package/layers/admin/app/composables/useEditForm.ts +0 -402
  85. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  86. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  87. package/layers/admin/app/composables/useListColumns.ts +0 -53
  88. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  89. package/layers/admin/app/composables/useListRows.ts +0 -75
  90. package/layers/admin/app/composables/useListSelection.ts +0 -28
  91. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  92. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  93. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  94. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  95. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  96. package/layers/admin/app/composables/useTheme.ts +0 -36
  97. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  98. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  99. package/layers/admin/app/layouts/admin.vue +0 -266
  100. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  101. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  102. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  103. package/layers/admin/app/pages/admin/index.vue +0 -118
  104. package/layers/admin/app/pages/admin/login.vue +0 -68
  105. package/layers/admin/app/pages/admin/media.vue +0 -28
  106. package/layers/admin/app/pages/admin/references.vue +0 -146
  107. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  108. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  109. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  110. package/layers/admin/app/utils/block-tree.ts +0 -214
  111. package/layers/admin/app/utils/collection-ops.ts +0 -69
  112. package/layers/admin/app/utils/dead-refs.ts +0 -30
  113. package/layers/admin/app/utils/edit-form.ts +0 -267
  114. package/layers/admin/app/utils/editor-expose.ts +0 -36
  115. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  116. package/layers/admin/app/utils/editor-registry.ts +0 -20
  117. package/layers/admin/app/utils/field-keys.ts +0 -11
  118. package/layers/admin/app/utils/list-cell.ts +0 -46
  119. package/layers/admin/app/utils/list-columns.ts +0 -142
  120. package/layers/admin/app/utils/list-query.ts +0 -96
  121. package/layers/admin/app/utils/nav-active.ts +0 -9
  122. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  123. package/layers/admin/app/utils/preview-channel.ts +0 -62
  124. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  125. package/layers/admin/app/utils/reauth.ts +0 -67
  126. package/layers/admin/app/utils/record-title.ts +0 -15
  127. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  128. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  129. package/layers/admin/nuxt.config.ts +0 -20
  130. package/layers/auth/nuxt.config.ts +0 -1
  131. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  132. package/layers/collections/nuxt.config.ts +0 -1
  133. package/layers/collections/server/plugins/01.register.ts +0 -29
  134. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  135. package/layers/core/app/composables/layouts.ts +0 -5
  136. package/layers/core/app/utils/field-factories.ts +0 -129
  137. package/layers/core/app/utils/layouts.ts +0 -38
  138. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  139. package/layers/core/modules/auto-discovery/index.ts +0 -112
  140. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  141. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  142. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  143. package/layers/core/modules/component-namespace/index.ts +0 -44
  144. package/layers/core/modules/component-namespace/shared.ts +0 -24
  145. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  146. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  147. package/layers/core/modules/kestrel/index.ts +0 -144
  148. package/layers/core/modules/plugin-order/index.ts +0 -69
  149. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  150. package/layers/core/nuxt.config.ts +0 -10
  151. package/layers/core/server/api/[...path].ts +0 -98
  152. package/layers/core/server/plugins/00.config.ts +0 -12
  153. package/layers/core/server/plugins/00.migrate.ts +0 -12
  154. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  155. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  156. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  157. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  158. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  159. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  160. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  161. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  162. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  163. package/layers/core/server/tasks/db/migrate.ts +0 -50
  164. package/layers/core/server/utils/server-config.ts +0 -81
  165. package/layers/fields/nuxt.config.ts +0 -1
  166. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  167. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  168. package/layers/media/app/components/KestrelImg.vue +0 -94
  169. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  170. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  171. package/layers/media/app/components/MediaGrid.vue +0 -113
  172. package/layers/media/app/components/MediaImage.vue +0 -70
  173. package/layers/media/app/components/MediaLibrary.vue +0 -456
  174. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  175. package/layers/media/app/components/MediaPathBar.vue +0 -163
  176. package/layers/media/app/components/MediaPicker.vue +0 -19
  177. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  178. package/layers/media/app/components/MediaTable.vue +0 -75
  179. package/layers/media/app/components/MediaThumb.vue +0 -25
  180. package/layers/media/app/components/MediaToolbar.vue +0 -90
  181. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  182. package/layers/media/app/components/MediaViewer.vue +0 -137
  183. package/layers/media/app/components/field/Media.vue +0 -263
  184. package/layers/media/app/composables/useBlurUp.ts +0 -55
  185. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  186. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  187. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  188. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  189. package/layers/media/app/composables/useMediaOps.ts +0 -50
  190. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  191. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  192. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  193. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  194. package/layers/media/app/plugins/field-media.client.ts +0 -6
  195. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  196. package/layers/media/app/utils/dnd.ts +0 -69
  197. package/layers/media/app/utils/library.ts +0 -81
  198. package/layers/media/app/utils/ops.ts +0 -60
  199. package/layers/media/app/utils/picture-model.ts +0 -125
  200. package/layers/media/app/utils/thumbhash.ts +0 -19
  201. package/layers/media/app/utils/variant-name.ts +0 -20
  202. package/layers/media/nuxt.config.ts +0 -7
  203. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  204. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  205. package/layers/media/server/plugins/02.register-media.ts +0 -22
  206. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  207. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  208. package/layers/media/server/tasks/media/backfill.ts +0 -17
  209. package/layers/public/app/app.vue +0 -5
  210. package/layers/public/app/components/BlockRenderer.vue +0 -56
  211. package/layers/public/app/components/KestrelLink.vue +0 -26
  212. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  213. package/layers/public/app/composables/public-page.ts +0 -24
  214. package/layers/public/app/error.vue +0 -12
  215. package/layers/public/app/layouts/default.vue +0 -5
  216. package/layers/public/app/pages/[...slug].vue +0 -239
  217. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  218. package/layers/public/app/utils/block-edit-context.ts +0 -16
  219. package/layers/public/app/utils/json-ld.ts +0 -139
  220. package/layers/public/app/utils/link-href.ts +0 -46
  221. package/layers/public/app/utils/page-head.ts +0 -84
  222. package/layers/public/app/utils/page-layout.ts +0 -18
  223. package/layers/public/app/utils/preview-protocol.ts +0 -117
  224. package/layers/public/app/utils/public-route.ts +0 -46
  225. package/layers/public/app/utils/site-head.ts +0 -43
  226. package/layers/public/modules/deploy-output/index.ts +0 -80
  227. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  228. package/layers/public/modules/prerender-routes/index.ts +0 -88
  229. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  230. package/layers/public/modules/prune-media/index.ts +0 -48
  231. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  232. package/layers/public/nuxt.config.ts +0 -14
  233. package/layers/public/server/delivery-live/serve.ts +0 -111
  234. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  235. package/layers/public/server/pipelines/index.ts +0 -23
  236. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  237. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  238. package/layers/public/server/plugins/02.register-links.ts +0 -16
  239. package/layers/public/server/plugins/03.redirects.ts +0 -55
  240. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  241. package/layers/public/server/plugins/zz.publish.ts +0 -127
  242. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  243. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  244. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  245. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  246. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  247. package/layers/public/server/tasks/publish/run.ts +0 -32
  248. package/layers/public/server/utils/publish/render-live.ts +0 -26
  249. package/layers/ui/app/assets/scss/_base.scss +0 -16
  250. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  251. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  252. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  253. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  254. package/layers/ui/app/assets/scss/main.scss +0 -4
  255. package/layers/ui/app/components/field/Boolean.vue +0 -30
  256. package/layers/ui/app/components/field/Choice.vue +0 -73
  257. package/layers/ui/app/components/field/Datetime.vue +0 -98
  258. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  259. package/layers/ui/app/components/field/Json.vue +0 -62
  260. package/layers/ui/app/components/field/Layout.vue +0 -138
  261. package/layers/ui/app/components/field/Link.vue +0 -112
  262. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  263. package/layers/ui/app/components/field/Number.vue +0 -30
  264. package/layers/ui/app/components/field/Relation.vue +0 -49
  265. package/layers/ui/app/components/field/Renderer.vue +0 -30
  266. package/layers/ui/app/components/field/Repeater.vue +0 -460
  267. package/layers/ui/app/components/field/Richtext.vue +0 -26
  268. package/layers/ui/app/components/field/Slug.vue +0 -65
  269. package/layers/ui/app/components/field/Text.vue +0 -35
  270. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  271. package/layers/ui/app/components/ui/Alert.vue +0 -50
  272. package/layers/ui/app/components/ui/Brand.vue +0 -22
  273. package/layers/ui/app/components/ui/Button.vue +0 -116
  274. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  275. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  276. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  277. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  278. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  279. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  280. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  281. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  282. package/layers/ui/app/components/ui/Field.vue +0 -58
  283. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  284. package/layers/ui/app/components/ui/Icon.vue +0 -44
  285. package/layers/ui/app/components/ui/Menu.vue +0 -54
  286. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  287. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  288. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  289. package/layers/ui/app/components/ui/Select.vue +0 -52
  290. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  291. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  292. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  293. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  294. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  295. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  296. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  297. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  298. package/layers/ui/app/composables/useLinkField.ts +0 -70
  299. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  300. package/layers/ui/app/composables/useRepeater.ts +0 -99
  301. package/layers/ui/app/composables/useT.ts +0 -32
  302. package/layers/ui/app/composables/useToast.ts +0 -46
  303. package/layers/ui/app/i18n/de.ts +0 -432
  304. package/layers/ui/app/i18n/en.ts +0 -434
  305. package/layers/ui/app/utils/boolean-field.ts +0 -10
  306. package/layers/ui/app/utils/field-component.ts +0 -22
  307. package/layers/ui/app/utils/field-empty.ts +0 -21
  308. package/layers/ui/app/utils/field-registry.ts +0 -36
  309. package/layers/ui/app/utils/humanize.ts +0 -14
  310. package/layers/ui/app/utils/icons.ts +0 -143
  311. package/layers/ui/app/utils/iso-date.ts +0 -18
  312. package/layers/ui/app/utils/localized.ts +0 -13
  313. package/layers/ui/app/utils/record-context.ts +0 -15
  314. package/layers/ui/app/utils/reorder.ts +0 -8
  315. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  316. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  317. package/layers/ui/nuxt.config.ts +0 -9
  318. package/nuxt.config.ts +0 -108
  319. package/scripts/consumer-template-ci.mjs +0 -160
  320. package/scripts/copy-create-payload.mjs +0 -49
  321. package/scripts/coverage-all.mjs +0 -163
  322. package/scripts/dashboard.mjs +0 -89
  323. package/scripts/hash-password.mjs +0 -29
  324. package/scripts/kestrel.mjs +0 -240
  325. package/scripts/lib/cli.mjs +0 -129
  326. package/scripts/lib/coverage-model.mjs +0 -51
  327. package/scripts/lib/password.mjs +0 -19
  328. package/scripts/lib/scaffold.mjs +0 -205
  329. package/scripts/port-graph.mjs +0 -350
  330. package/scripts/typecheck.mjs +0 -62
  331. package/templates/starter/README.md +0 -65
  332. package/templates/starter/_env.example +0 -17
  333. package/templates/starter/_gitignore +0 -26
  334. package/templates/starter/_package.json +0 -22
  335. package/templates/starter/app/app.vue +0 -7
  336. package/templates/starter/app/blocks/Prose.vue +0 -13
  337. package/templates/starter/app/layouts/default.vue +0 -6
  338. package/templates/starter/nuxt.config.ts +0 -23
  339. package/templates/starter/pnpm-workspace.yaml +0 -8
  340. package/templates/starter/tsconfig.json +0 -3
@@ -1,51 +0,0 @@
1
- // Indexed view over an Istanbul-shaped coverage report, for the audit detectors.
2
- //
3
- // Two questions only: which branch arms were never taken, and which functions never ran. Both are
4
- // keyed by line so they can be joined to the graph, whose nodes carry a start line and no span.
5
- import { readFileSync, statSync } from 'node:fs'
6
- import { join, relative, isAbsolute } from 'node:path'
7
-
8
- export function loadCoverage(root, file = 'reports/coverage/coverage-final.json') {
9
- const path = isAbsolute(file) ? file : join(root, file)
10
- let raw
11
- let generatedAt
12
- try {
13
- raw = JSON.parse(readFileSync(path, 'utf8'))
14
- generatedAt = statSync(path).mtimeMs
15
- } catch {
16
- return null
17
- }
18
-
19
- const byFile = new Map()
20
- for (const entry of Object.values(raw)) {
21
- const rel = relative(root, entry.path)
22
- const missedBranchLines = new Set()
23
- let branchMissCount = 0
24
- for (const [key, arm] of Object.entries(entry.branchMap ?? {})) {
25
- const counts = entry.b?.[key] ?? []
26
- arm.locations?.forEach((location, index) => {
27
- if (counts[index] !== 0) return
28
- branchMissCount += 1
29
- const line = location.start?.line ?? arm.line
30
- if (line) missedBranchLines.add(line)
31
- })
32
- }
33
- const ranFunctionLines = new Set()
34
- for (const [key, fn] of Object.entries(entry.fnMap ?? {})) {
35
- if (!entry.f?.[key]) continue
36
- const line = fn.decl?.start?.line ?? fn.loc?.start?.line
37
- if (line) ranFunctionLines.add(line)
38
- }
39
- byFile.set(rel, { missedBranchLines, branchMissCount, ranFunctionLines })
40
- }
41
-
42
- const empty = { missedBranchLines: new Set(), branchMissCount: 0, ranFunctionLines: new Set() }
43
- return {
44
- generatedAt,
45
- files: [...byFile.keys()],
46
- has: (file) => byFile.has(file),
47
- missedBranchLines: (file) => (byFile.get(file) ?? empty).missedBranchLines,
48
- branchMissCount: (file) => (byFile.get(file) ?? empty).branchMissCount,
49
- ranFunctionLines: (file) => (byFile.get(file) ?? empty).ranFunctionLines,
50
- }
51
- }
@@ -1,19 +0,0 @@
1
- import { scryptSync, randomBytes } from 'node:crypto'
2
-
3
- // Must match what layers/auth parses back out. ADR-0001 covers the parameter choice.
4
- const N = 2 ** 17
5
- const r = 8
6
- const p = 1
7
- const KEYLEN = 64
8
- const MAXMEM = 256 * 1024 * 1024
9
-
10
- /** `scrypt$N$r$p$salt$hash`, the `KESTREL_ADMIN_PASSWORD_HASH` format. */
11
- export function hashPassword(password) {
12
- const salt = randomBytes(16)
13
- const hash = scryptSync(password, salt, KEYLEN, { N, r, p, maxmem: MAXMEM })
14
- return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`
15
- }
16
-
17
- export function sessionSecret() {
18
- return randomBytes(32).toString('base64url')
19
- }
@@ -1,205 +0,0 @@
1
- export const PACKAGE_NAME = '@michaelthielemann/kestrel'
2
-
3
- // Template dotfiles are `_`-prefixed because npm strips a literal `.gitignore` from a tarball. ADR-0005.
4
- const RENAMES = {
5
- _gitignore: '.gitignore',
6
- '_env.example': '.env.example',
7
- '_package.json': 'package.json',
8
- }
9
-
10
- export function targetName(rel) {
11
- const parts = rel.split('/')
12
- const base = parts[parts.length - 1]
13
- parts[parts.length - 1] = RENAMES[base] ?? base
14
- return parts.join('/')
15
- }
16
-
17
- /** An unknown placeholder is left as-is so a typo is visible rather than silently blank. */
18
- export function renderTemplate(src, vars) {
19
- return src.replace(/\{\{(\w+)\}\}/g, (whole, key) => (key in vars ? String(vars[key]) : whole))
20
- }
21
-
22
- export function toPackageName(dirName) {
23
- const slug = dirName
24
- .toLowerCase()
25
- .replace(/[^a-z0-9._-]+/g, '-')
26
- .replace(/^[-._]+|[-._]+$/g, '')
27
- return slug || 'kestrel-site'
28
- }
29
-
30
- const KEY_RE = /^\s*([A-Z][A-Z0-9_]*)\s*=/
31
-
32
- /**
33
- * Fills only keys that are absent or empty, so re-running `init` never rotates a live session secret.
34
- * A key in `force` replaces a value that is already there — for a secret the caller supplied by hand,
35
- * where being handed one at all is the instruction to change it.
36
- * Returns the new text plus the keys that changed.
37
- */
38
- export function mergeEnv(existing, entries, force = []) {
39
- const forced = new Set(force)
40
- const pending = new Map(Object.entries(entries))
41
- const lines = existing === '' ? [] : existing.split('\n')
42
- const written = []
43
-
44
- // A dotenv loader takes the LAST assignment of a key, so filling the first would leave the file
45
- // reporting a value the app never sees.
46
- const lastIndex = new Map()
47
- lines.forEach((line, i) => {
48
- const key = KEY_RE.exec(line)?.[1]
49
- if (key) lastIndex.set(key, i)
50
- })
51
-
52
- const out = lines.map((line, i) => {
53
- const key = KEY_RE.exec(line)?.[1]
54
- if (!key || !pending.has(key) || lastIndex.get(key) !== i) return line
55
- const value = pending.get(key)
56
- pending.delete(key)
57
- if (line.slice(line.indexOf('=') + 1).trim() !== '' && !forced.has(key)) return line
58
- written.push(key)
59
- return `${key}=${value}`
60
- })
61
-
62
- if (pending.size) {
63
- if (out.length && out[out.length - 1].trim() !== '') out.push('')
64
- for (const [key, value] of pending) {
65
- out.push(`${key}=${value}`)
66
- written.push(key)
67
- }
68
- out.push('')
69
- }
70
-
71
- return { text: out.join('\n'), written }
72
- }
73
-
74
- /** `typeof null === 'object'`, and arrays are objects too — both need explicit exclusion for a plain object check. */
75
- export const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
76
-
77
- const NESTED = ['scripts', 'dependencies', 'devDependencies']
78
- // `type` decides whether every .js in the project is ESM or CJS, so injecting it silently could break a
79
- // CommonJS project outright. Only these top-level keys may be introduced, and each is reported.
80
- const TOP_LEVEL = ['name', 'private', 'type']
81
-
82
- /** Additive: the project's own values always win. Returns the manifest plus every key it introduced. */
83
- export function mergePackageJson(existing, template) {
84
- const merged = { ...existing }
85
- const added = []
86
-
87
- for (const key of TOP_LEVEL) {
88
- if (!(key in template) || key in existing) continue
89
- merged[key] = template[key]
90
- added.push(key)
91
- }
92
- for (const key of NESTED) {
93
- if (!template[key]) continue
94
- merged[key] = isPlainObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
95
- for (const name of Object.keys(template[key])) {
96
- if (!isPlainObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
97
- }
98
- }
99
- return { merged, added }
100
- }
101
-
102
- /**
103
- * The value a dotenv key carries, or `undefined`/`''` when it carries none.
104
- * Horizontal whitespace only: `\s*` would let an empty assignment match the NEXT line's value.
105
- */
106
- export const envValue = (env, key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
107
-
108
- const withoutComments = (src) => src.replace(/<!--[\s\S]*?-->/g, '')
109
- // `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
110
- const kebab = (tag) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
111
- export const usesComponent = (src, tag) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
112
-
113
- /** Whether `output.publishOnSave` resolves to `true` — config or its env override, same precedence as
114
- * `resolveKestrel` (env wins when it names a valid boolean; otherwise the config file's own value). */
115
- function publishOnSaveEnabled(kestrelConfig, env) {
116
- const fromEnv = env !== null ? envValue(env, 'KESTREL_OUTPUT_PUBLISH_ON_SAVE')?.toLowerCase() : undefined
117
- if (fromEnv !== undefined) {
118
- if (['true', '1', 'yes', 'on'].includes(fromEnv)) return true
119
- if (['false', '0', 'no', 'off'].includes(fromEnv)) return false
120
- }
121
- return typeof kestrelConfig === 'string' && /\bpublishOnSave\s*:\s*true\b/.test(kestrelConfig)
122
- }
123
-
124
- /**
125
- * `kestrel doctor`, as a pure function of what was read; `null` means the file is absent.
126
- * The `app.vue` rules mirror `layers/core/modules/kestrel/app-shell.ts`; a test pins the two together.
127
- */
128
- export function diagnoseProject({ packageJson, nuxtConfig, appVue, env, kestrelConfig }) {
129
- const found = []
130
- const add = (level, message) => found.push({ level, message })
131
-
132
- let manifest
133
- if (packageJson !== null) {
134
- try {
135
- manifest = JSON.parse(packageJson)
136
- } catch {
137
- add('error', 'package.json is not valid JSON — nothing can read it, including pnpm.')
138
- }
139
- }
140
-
141
- if (packageJson === null) {
142
- add('error', 'no package.json — run this inside a project directory, or `kestrel init <dir>` to make one.')
143
- } else if (manifest !== undefined) {
144
- const deps = { ...manifest.dependencies, ...manifest.devDependencies }
145
- if (!(PACKAGE_NAME in deps)) add('error', `${PACKAGE_NAME} is not a dependency — run \`pnpm add ${PACKAGE_NAME}\`.`)
146
- if (!('nuxt' in deps)) {
147
- add(
148
- 'error',
149
- 'nuxt is not a direct dependency. Kestrel depends on it, but a strict node_modules layout (pnpm) does not link a transitive package\'s `nuxt` binary, so `nuxt dev` will not resolve — run `pnpm add -D nuxt`.',
150
- )
151
- }
152
- if (!manifest.scripts?.dev) add('warn', 'no `dev` script — add `"dev": "nuxt dev"` so `pnpm dev` works.')
153
- }
154
-
155
- if (nuxtConfig === null) {
156
- add(
157
- 'error',
158
- 'no nuxt.config.ts. Installing the package does nothing on its own: Nuxt only loads Kestrel when the config extends it. Without this file every route, /admin included, serves the default Nuxt welcome page.',
159
- )
160
- } else if (!nuxtConfig.includes(PACKAGE_NAME)) {
161
- add('error', `nuxt.config.ts does not extend ${PACKAGE_NAME} — add \`extends: ['${PACKAGE_NAME}']\`.`)
162
- } else if (/extends\s*:\s*\[[^\]]*['"]\.\.[/\\]\.\./.test(nuxtConfig)) {
163
- add(
164
- 'warn',
165
- "a relative `extends` path of '../..' or deeper silently drops every Kestrel sub-layer (c12 treats it as a file, not a directory, because pathe reports its extension as '.'). Use the package name.",
166
- )
167
- }
168
-
169
- if (appVue !== null) {
170
- const src = withoutComments(appVue)
171
- if (!usesComponent(src, 'NuxtPage')) {
172
- add(
173
- 'error',
174
- 'app/app.vue renders no <NuxtPage />, so no route renders — including /admin. It shadows the one Kestrel ships. Delete it, or wrap <NuxtPage /> in <NuxtLayout>.',
175
- )
176
- } else if (!usesComponent(src, 'NuxtLayout')) {
177
- add('warn', 'app/app.vue renders no <NuxtLayout />, so the admin loses its navigation shell.')
178
- }
179
- }
180
-
181
- if (env === null) {
182
- add('error', 'no .env — sign-in at /admin answers 503 until KESTREL_ADMIN_PASSWORD_HASH is set. Run `kestrel init`.')
183
- } else {
184
- const value = (key) => envValue(env, key)
185
- if (!value('KESTREL_ADMIN_PASSWORD_HASH')) {
186
- add('error', 'KESTREL_ADMIN_PASSWORD_HASH is unset — /admin renders but sign-in answers 503. Run `kestrel hash-password`.')
187
- }
188
- if (!value('KESTREL_SESSION_SECRET')) {
189
- add('warn', 'KESTREL_SESSION_SECRET is unset — dev falls back to a random per-process secret (sessions drop on restart) and production refuses to boot.')
190
- }
191
- }
192
-
193
- // Informational, not a misconfiguration: `publishOnSave: false` is the correct 2.0 default. Named
194
- // because it is a silent behaviour change from 1.x — a headless consumer that only PATCHes content and
195
- // never opens /admin gets no other signal that saves stopped reaching the static output.
196
- if (!publishOnSaveEnabled(kestrelConfig, env)) {
197
- add(
198
- 'info',
199
- 'saving a record no longer publishes it by default — only an explicit Publish action does (ADR-0008). ' +
200
- 'Set `output.publishOnSave: true` in kestrel.config.ts, or KESTREL_OUTPUT_PUBLISH_ON_SAVE=1, to restore the pre-2.0 behaviour.',
201
- )
202
- }
203
-
204
- return found
205
- }
@@ -1,350 +0,0 @@
1
- #!/usr/bin/env node
2
- // Port-graph extraction (experiment): for every exported function in packages/kestrel-core/src whose
3
- // return type resolves to `Effect.Effect<A, E, R>`, the R (requirements) channel becomes that function's
4
- // port list. Every `Layer.succeed`/`Layer.effect`/`Layer.scoped` call becomes an adapter binding from the
5
- // layer's containing function to the port it provides. Nodes reuse graphify-out/graph.json's id scheme so
6
- // the two graphs join; a node not already present there is synthesized with the same scheme and flagged.
7
- //
8
- // The R channel alone cannot answer "does persist always follow validate" — this codebase passes the
9
- // database as a plain function parameter, not a Context.Tag requirement, so R is `never` on every step.
10
- // To make that invariant askable as a graph query at all, this script also extracts each `definePipeline`
11
- // call's literal `steps: [...]` array (resolving each step-factory call to the runtime step name registered
12
- // via `syncStep(name, ...)`) into an ordered step chain, and exposes `stepOrderRespectsInvariant` for
13
- // querying it.
14
- import ts from 'typescript'
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
16
- import { dirname, join, relative } from 'node:path'
17
- import { fileURLToPath, pathToFileURL } from 'node:url'
18
-
19
- const root = join(dirname(fileURLToPath(import.meta.url)), '..')
20
- const corePkg = join(root, 'packages/kestrel-core')
21
- const graphOutDir = join(root, 'graphify-out')
22
-
23
- // --- graph id scheme (mirrors graphify's own slugging, see graphify-out/graph.json) ---
24
-
25
- function slugFile(relFile) {
26
- return relFile.replace(/\.[^/.]+$/, '').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()
27
- }
28
-
29
- function slugSymbol(name) {
30
- return name.replace(/\(\)$/, '').toLowerCase().replace(/[^a-z0-9]/g, '')
31
- }
32
-
33
- function loadExistingGraph() {
34
- const graphPath = join(graphOutDir, 'graph.json')
35
- if (!existsSync(graphPath)) return { nodes: [], byFileAndLabel: new Map() }
36
- const graph = JSON.parse(readFileSync(graphPath, 'utf8'))
37
- const byFileAndLabel = new Map()
38
- for (const n of graph.nodes) byFileAndLabel.set(`${n.source_file}::${n.label}`, n)
39
- return { nodes: graph.nodes, byFileAndLabel }
40
- }
41
-
42
- /** Reuses the existing graph's node id for (relFile, name) when present; otherwise synthesizes one with
43
- * the same scheme and marks it so a future graphify run can be checked against it. */
44
- function nodeIdFor(existing, relFile, name) {
45
- const found = existing.byFileAndLabel.get(`${relFile}::${name}()`)
46
- if (found) return { id: found.id, synthesized: false }
47
- return { id: `${slugFile(relFile)}_${slugSymbol(name)}`, synthesized: true }
48
- }
49
-
50
- function loadProgram() {
51
- const tsconfigPath = join(corePkg, 'tsconfig.json')
52
- const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile)
53
- const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, corePkg)
54
- const fileNames = parsed.fileNames.filter((f) => !/\.test\.ts$/.test(f) && !f.includes(`${corePkg}/test/`))
55
- const program = ts.createProgram(fileNames, { ...parsed.options, noEmit: true })
56
- return { program, fileNames }
57
- }
58
-
59
- function hasExportModifier(node) {
60
- return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0
61
- }
62
-
63
- function isEffectReturnType(type) {
64
- const target = type.aliasSymbol ?? type.target?.symbol ?? type.symbol
65
- return target?.name === 'Effect' && !!(type).typeArguments
66
- }
67
-
68
- /** Flattens a union of `Context.Tag`-shaped types (or a bare one) into distinct display names, dropping
69
- * `never` (the honest "no requirements" answer, not an omission). */
70
- function portNamesFromRType(checker, rType) {
71
- if (!rType) return []
72
- const parts = rType.isUnion?.() ? rType.types : [rType]
73
- const names = new Set()
74
- for (const t of parts) {
75
- const text = checker.typeToString(t)
76
- if (text === 'never' || text === 'unknown') continue
77
- names.add(text)
78
- }
79
- return [...names]
80
- }
81
-
82
- /** Visits every exported function-like declaration (function declarations and `export const f = (...) =>`)
83
- * in a source file, at the top level and one level of nesting (namespaces are not used in this codebase). */
84
- function* exportedFunctions(sf) {
85
- for (const stmt of sf.statements) {
86
- if (ts.isFunctionDeclaration(stmt) && hasExportModifier(stmt) && stmt.name) {
87
- yield { name: stmt.name.text, decl: stmt }
88
- } else if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
89
- for (const d of stmt.declarationList.declarations) {
90
- if (ts.isIdentifier(d.name) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) {
91
- yield { name: d.name.text, decl: d.initializer }
92
- }
93
- }
94
- }
95
- }
96
- }
97
-
98
- function extractFunctionPorts(program, existing) {
99
- const checker = program.getTypeChecker()
100
- const functions = []
101
- let scanned = 0
102
- for (const sf of program.getSourceFiles()) {
103
- if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
104
- const relFile = relative(root, sf.fileName)
105
- for (const { name, decl } of exportedFunctions(sf)) {
106
- scanned++
107
- const sig = checker.getSignatureFromDeclaration(decl)
108
- if (!sig) continue
109
- const returnType = checker.getReturnTypeOfSignature(sig)
110
- if (!isEffectReturnType(returnType)) continue
111
- const typeArgs = checker.getTypeArguments(returnType)
112
- const rType = typeArgs[2]
113
- const ports = portNamesFromRType(checker, rType)
114
- const { id, synthesized } = nodeIdFor(existing, relFile, name)
115
- functions.push({ id, name, file: relFile, ports, synthesized })
116
- }
117
- }
118
- return { functions, scanned }
119
- }
120
-
121
- function containingFunctionName(node) {
122
- let cur = node.parent
123
- while (cur) {
124
- if ((ts.isFunctionDeclaration(cur) || ts.isFunctionExpression(cur) || ts.isArrowFunction(cur)) ) {
125
- if (ts.isFunctionDeclaration(cur) && cur.name) return cur.name.text
126
- if (ts.isVariableDeclaration(cur.parent) && ts.isIdentifier(cur.parent.name)) return cur.parent.name.text
127
- }
128
- cur = cur.parent
129
- }
130
- return null
131
- }
132
-
133
- /** Resolves a `Context.GenericTag<X>(...)` (or `Context.Tag<X, ...>`) expression, following one level of
134
- * identifier indirection, to the service type name `X`. */
135
- function resolveTagTypeName(checker, expr) {
136
- let target = expr
137
- if (ts.isIdentifier(expr)) {
138
- const sym = checker.getSymbolAtLocation(expr)
139
- const decl = sym?.valueDeclaration
140
- if (decl && ts.isVariableDeclaration(decl) && decl.initializer) target = decl.initializer
141
- }
142
- if (ts.isCallExpression(target) && target.typeArguments?.length) {
143
- return target.typeArguments[0].getText()
144
- }
145
- return null
146
- }
147
-
148
- const LAYER_CONSTRUCTORS = new Set(['succeed', 'effect', 'scoped', 'sync'])
149
-
150
- function extractLayerBindings(program, existing) {
151
- const checker = program.getTypeChecker()
152
- const bindings = []
153
- for (const sf of program.getSourceFiles()) {
154
- if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
155
- const relFile = relative(root, sf.fileName)
156
- const visit = (node) => {
157
- if (
158
- ts.isCallExpression(node)
159
- && ts.isPropertyAccessExpression(node.expression)
160
- && ts.isIdentifier(node.expression.expression)
161
- && node.expression.expression.text === 'Layer'
162
- && LAYER_CONSTRUCTORS.has(node.expression.name.text)
163
- && node.arguments.length > 0
164
- ) {
165
- const tagName = resolveTagTypeName(checker, node.arguments[0])
166
- if (tagName) {
167
- const fnName = containingFunctionName(node) ?? '<module scope>'
168
- const { id, synthesized } = nodeIdFor(existing, relFile, fnName)
169
- bindings.push({
170
- layerFnId: id,
171
- layerFnName: fnName,
172
- file: relFile,
173
- line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
174
- constructor: node.expression.name.text,
175
- port: tagName,
176
- synthesized,
177
- })
178
- }
179
- }
180
- ts.forEachChild(node, visit)
181
- }
182
- visit(sf)
183
- }
184
- return bindings
185
- }
186
-
187
- // --- Pipeline step-order extraction (makes the validate-before-persist invariant askable as a graph query) ---
188
-
189
- /** Maps every step-factory function name (e.g. `validateCreateStep`) to the runtime step name it registers
190
- * via `syncStep('<name>', ...)` / `asyncStep('<name>', ...)`. A factory with more than one such call inside
191
- * keeps only the first — every factory in this codebase registers exactly one step. */
192
- function buildStepNameMap(program) {
193
- const map = new Map()
194
- for (const sf of program.getSourceFiles()) {
195
- if (!sf.fileName.includes('/pipeline/steps/') || sf.fileName.includes('/node_modules/')) continue
196
- for (const { name, decl } of exportedFunctions(sf)) {
197
- let stepName = null
198
- const visit = (node) => {
199
- if (stepName) return
200
- if (
201
- ts.isCallExpression(node)
202
- && ts.isIdentifier(node.expression)
203
- && (node.expression.text === 'syncStep' || node.expression.text === 'asyncStep')
204
- && node.arguments[0] && ts.isStringLiteral(node.arguments[0])
205
- ) {
206
- stepName = node.arguments[0].text
207
- return
208
- }
209
- ts.forEachChild(node, visit)
210
- }
211
- visit(decl)
212
- if (stepName) map.set(name, stepName)
213
- }
214
- }
215
- return map
216
- }
217
-
218
- function extractPipelineStepChains(program) {
219
- const stepNameMap = buildStepNameMap(program)
220
- const pipelines = []
221
- for (const sf of program.getSourceFiles()) {
222
- if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
223
- const relFile = relative(root, sf.fileName)
224
- const visit = (node) => {
225
- if (
226
- ts.isCallExpression(node)
227
- && ts.isIdentifier(node.expression)
228
- && node.expression.text === 'definePipeline'
229
- && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])
230
- ) {
231
- const obj = node.arguments[0]
232
- const nameProp = obj.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'name')
233
- const stepsProp = obj.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'steps')
234
- if (nameProp && ts.isStringLiteral(nameProp.initializer) && stepsProp && ts.isArrayLiteralExpression(stepsProp.initializer)) {
235
- const steps = []
236
- for (const el of stepsProp.initializer.elements) {
237
- // `steps: deleteSteps` (an identifier referring to a separately declared array) is skipped —
238
- // rollback/duplicate/delete are not in VALIDATING_WRITE_OPS, so the invariant query never needs it.
239
- if (ts.isCallExpression(el) && ts.isIdentifier(el.expression)) {
240
- const factoryName = el.expression.text
241
- steps.push(stepNameMap.get(factoryName) ?? factoryName)
242
- } else if (ts.isCallExpression(el) && ts.isPropertyAccessExpression(el.expression)) {
243
- steps.push(el.expression.name.text)
244
- }
245
- }
246
- pipelines.push({ name: nameProp.initializer.text, file: relFile, line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1, steps })
247
- }
248
- }
249
- ts.forEachChild(node, visit)
250
- }
251
- visit(sf)
252
- }
253
- return pipelines
254
- }
255
-
256
- /** The validate-before-persist invariant, as a pure query over an ordered step-name chain: `validate` must occur, `persist`
257
- * must occur, and `validate` must precede `persist`. Mirrors test/architecture/pipeline-invariants.test.ts'
258
- * registry-based check, but over the statically extracted `definePipeline` step array instead of a
259
- * runtime-composed `PipelineDescriptor`. */
260
- export function stepOrderRespectsInvariant(stepNames) {
261
- const validateAt = stepNames.indexOf('validate')
262
- const persistAt = stepNames.indexOf('persist')
263
- if (validateAt < 0 || persistAt < 0) return false
264
- return validateAt < persistAt
265
- }
266
-
267
- export const VALIDATING_WRITE_OPS = new Set(['createOne', 'createMany', 'updateOne', 'updateMany'])
268
-
269
- export function extractPortGraph() {
270
- const { program } = loadProgram()
271
- const existing = loadExistingGraph()
272
- const { functions, scanned } = extractFunctionPorts(program, existing)
273
- const bindings = extractLayerBindings(program, existing)
274
- const pipelines = extractPipelineStepChains(program)
275
- return { functions, scanned, bindings, pipelines }
276
- }
277
-
278
- function buildEmittedGraph({ functions, bindings, pipelines }) {
279
- const nodes = []
280
- const links = []
281
- const seenNodes = new Set()
282
- const addNode = (node) => {
283
- if (seenNodes.has(node.id)) return
284
- seenNodes.add(node.id)
285
- nodes.push(node)
286
- }
287
-
288
- for (const fn of functions) {
289
- addNode({ id: fn.id, label: `${fn.name}()`, kind: 'function', source_file: fn.file, synthesized: fn.synthesized })
290
- for (const port of fn.ports) {
291
- const portId = `port_${slugSymbol(port)}`
292
- addNode({ id: portId, label: port, kind: 'port' })
293
- links.push({ relation: 'requires', source: fn.id, target: portId, source_file: fn.file })
294
- }
295
- }
296
-
297
- for (const b of bindings) {
298
- addNode({ id: b.layerFnId, label: `${b.layerFnName}()`, kind: 'function', source_file: b.file, synthesized: b.synthesized })
299
- const portId = `port_${slugSymbol(b.port)}`
300
- addNode({ id: portId, label: b.port, kind: 'port' })
301
- links.push({ relation: 'provides', source: b.layerFnId, target: portId, source_file: b.file, source_location: `L${b.line}`, layer_constructor: b.constructor })
302
- }
303
-
304
- for (const [i, p] of pipelines.entries()) {
305
- const pipelineId = `pipeline_${slugFile(p.file)}_${slugSymbol(p.name)}_${i}`
306
- addNode({ id: pipelineId, label: `${p.name} (pipeline)`, kind: 'pipeline', source_file: p.file, source_location: `L${p.line}` })
307
- p.steps.forEach((stepName, order) => {
308
- const stepId = `pipeline_step_${slugSymbol(stepName)}`
309
- addNode({ id: stepId, label: stepName, kind: 'pipeline_step' })
310
- links.push({ relation: 'pipeline_step', source: pipelineId, target: stepId, order, source_file: p.file })
311
- })
312
- }
313
-
314
- return { nodes, links }
315
- }
316
-
317
- function main() {
318
- const { functions, scanned, bindings, pipelines } = extractPortGraph()
319
- const nonEmpty = functions.filter((f) => f.ports.length > 0)
320
- const totalPorts = new Set(functions.flatMap((f) => f.ports)).size
321
-
322
- const emitted = buildEmittedGraph({ functions, bindings, pipelines })
323
- const reportDir = join(root, 'reports/graph')
324
- mkdirSync(reportDir, { recursive: true })
325
- const outPath = join(reportDir, 'port-graph.json')
326
- writeFileSync(outPath, JSON.stringify({ generated_at: new Date().toISOString(), nodes: emitted.nodes, links: emitted.links }, null, 2))
327
-
328
- console.log(`port-graph: scanned ${scanned} exported functions in packages/kestrel-core/src`)
329
- console.log(`port-graph: ${functions.length} have an Effect.Effect<A, E, R> return type`)
330
- console.log(`port-graph: ${nonEmpty.length} have a non-empty (non-never) R channel; ${totalPorts} distinct ports found`)
331
- console.log(`port-graph: ${bindings.length} Layer composition(s) -> adapter binding(s) found`)
332
- console.log(`port-graph: ${pipelines.length} definePipeline() call(s) with a literal steps array extracted`)
333
- console.log(`port-graph: wrote ${emitted.nodes.length} nodes, ${emitted.links.length} links to ${relative(root, outPath)}`)
334
-
335
- if (nonEmpty.length === 0) {
336
- console.log('port-graph: honest finding — every scanned function has R = never (DB/services are plain')
337
- console.log('port-graph: parameters here, not Context.Tag requirements), so the R-channel port list is')
338
- console.log('port-graph: empty by construction. The pipeline_step chains above make the validate-before-persist')
339
- console.log('port-graph: invariant askable as a graph query at all; see test/architecture/port-graph-cross-check.test.ts.')
340
- }
341
-
342
- for (const p of pipelines) {
343
- if (!VALIDATING_WRITE_OPS.has(p.name)) continue
344
- const ok = stepOrderRespectsInvariant(p.steps)
345
- console.log(`port-graph: pipeline "${p.name}" (${p.file}:L${p.line}) validate-before-persist = ${ok}`)
346
- }
347
- }
348
-
349
- const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url
350
- if (isMain) main()