@michaelthielemann/kestrel 3.0.0 → 4.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.
- package/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +6 -8
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
7
|
+
const VITEST_BIN = path.join(ROOT, 'node_modules/.bin/vitest')
|
|
8
|
+
const PARTS_DIR = path.join(ROOT, 'reports/coverage/.parts')
|
|
9
|
+
const OUT_DIR = path.join(ROOT, 'reports/coverage')
|
|
10
|
+
|
|
11
|
+
function sameKeys(a, b) {
|
|
12
|
+
const ka = Object.keys(a)
|
|
13
|
+
const kb = Object.keys(b)
|
|
14
|
+
if (ka.length !== kb.length) return false
|
|
15
|
+
const setB = new Set(kb)
|
|
16
|
+
return ka.every((k) => setB.has(k))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mapsAgree(a, b) {
|
|
20
|
+
return sameKeys(a.statementMap, b.statementMap) && sameKeys(a.fnMap, b.fnMap) && sameKeys(a.branchMap, b.branchMap)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function countExecutedFunctions(entry) {
|
|
24
|
+
return Object.values(entry.f).filter((n) => n > 0).length
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneEntry(entry) {
|
|
28
|
+
return structuredClone(entry)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sumCounters(a, b) {
|
|
32
|
+
const out = {}
|
|
33
|
+
for (const k of Object.keys(a)) out[k] = a[k] + (b[k] ?? 0)
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sumBranches(a, b) {
|
|
38
|
+
const out = {}
|
|
39
|
+
for (const k of Object.keys(a)) {
|
|
40
|
+
const branchA = a[k]
|
|
41
|
+
const branchB = b[k] ?? []
|
|
42
|
+
out[k] = branchA.map((count, i) => count + (branchB[i] ?? 0))
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Istanbul JSON is keyed by absolute file path; the same path can show up in several parts
|
|
48
|
+
// (a package run and the root run both touching a shared file). When their instrumentation
|
|
49
|
+
// maps agree, counters are additive. When they disagree, the maps describe different
|
|
50
|
+
// transforms of the file — summing them would misattribute counts to the wrong statements/
|
|
51
|
+
// functions, so the richer entry wins outright and the conflict is reported instead of hidden.
|
|
52
|
+
export function mergeCoverage(parts, conflicts = []) {
|
|
53
|
+
const merged = {}
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
for (const [filePath, entry] of Object.entries(part)) {
|
|
56
|
+
const existing = merged[filePath]
|
|
57
|
+
if (!existing) {
|
|
58
|
+
merged[filePath] = cloneEntry(entry)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (mapsAgree(existing, entry)) {
|
|
62
|
+
merged[filePath] = {
|
|
63
|
+
...existing,
|
|
64
|
+
s: sumCounters(existing.s, entry.s),
|
|
65
|
+
f: sumCounters(existing.f, entry.f),
|
|
66
|
+
b: sumBranches(existing.b, entry.b),
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
if (countExecutedFunctions(entry) > countExecutedFunctions(existing)) {
|
|
70
|
+
merged[filePath] = cloneEntry(entry)
|
|
71
|
+
}
|
|
72
|
+
conflicts.push(filePath)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return merged
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readCoverage(dir) {
|
|
80
|
+
const file = path.join(dir, 'coverage-final.json')
|
|
81
|
+
if (!fs.existsSync(file)) return null
|
|
82
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function countFiles(coverage) {
|
|
86
|
+
return coverage ? Object.keys(coverage).length : 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function countFunctionsTotal(coverage) {
|
|
90
|
+
if (!coverage) return 0
|
|
91
|
+
let total = 0
|
|
92
|
+
for (const entry of Object.values(coverage)) total += countExecutedFunctions(entry)
|
|
93
|
+
return total
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function discoverPackageSuites() {
|
|
97
|
+
const packagesDir = path.join(ROOT, 'packages')
|
|
98
|
+
if (!fs.existsSync(packagesDir)) return []
|
|
99
|
+
return fs
|
|
100
|
+
.readdirSync(packagesDir)
|
|
101
|
+
.filter((name) => fs.existsSync(path.join(packagesDir, name, 'vitest.config.ts')))
|
|
102
|
+
.sort()
|
|
103
|
+
.map((name) => ({ name, cwd: path.join(packagesDir, name) }))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runSuite(name, cwd) {
|
|
107
|
+
const reportsDirectory = path.join(PARTS_DIR, name)
|
|
108
|
+
fs.mkdirSync(reportsDirectory, { recursive: true })
|
|
109
|
+
const args = [
|
|
110
|
+
'run',
|
|
111
|
+
'--coverage.enabled',
|
|
112
|
+
'--coverage.provider=v8',
|
|
113
|
+
'--coverage.reporter=json',
|
|
114
|
+
`--coverage.reportsDirectory=${reportsDirectory}`,
|
|
115
|
+
'--coverage.reportOnFailure=true',
|
|
116
|
+
]
|
|
117
|
+
console.log(`\n=== ${name} (${path.relative(ROOT, cwd) || '.'}) ===`)
|
|
118
|
+
const result = spawnSync(VITEST_BIN, args, { cwd, stdio: 'inherit' })
|
|
119
|
+
const passed = result.status === 0
|
|
120
|
+
const coverage = readCoverage(reportsDirectory)
|
|
121
|
+
return { name, passed, coverage }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function main() {
|
|
125
|
+
fs.rmSync(PARTS_DIR, { recursive: true, force: true })
|
|
126
|
+
fs.mkdirSync(PARTS_DIR, { recursive: true })
|
|
127
|
+
|
|
128
|
+
const suites = [{ name: 'root', cwd: ROOT }, ...discoverPackageSuites()]
|
|
129
|
+
const results = suites.map(({ name, cwd }) => runSuite(name, cwd))
|
|
130
|
+
|
|
131
|
+
const conflicts = []
|
|
132
|
+
const merged = mergeCoverage(
|
|
133
|
+
results.map((r) => r.coverage).filter(Boolean),
|
|
134
|
+
conflicts,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
fs.mkdirSync(OUT_DIR, { recursive: true })
|
|
138
|
+
fs.writeFileSync(path.join(OUT_DIR, 'coverage-final.json'), JSON.stringify(merged))
|
|
139
|
+
|
|
140
|
+
console.log('\n=== coverage-all summary ===')
|
|
141
|
+
for (const r of results) {
|
|
142
|
+
const status = r.passed ? 'PASS' : 'FAIL'
|
|
143
|
+
console.log(
|
|
144
|
+
`${r.name.padEnd(24)} ${status} files=${countFiles(r.coverage)} functionsExecuted=${countFunctionsTotal(r.coverage)}`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (conflicts.length > 0) {
|
|
149
|
+
console.log(`\n${conflicts.length} merge conflict(s) (kept the entry with more executed functions):`)
|
|
150
|
+
for (const filePath of conflicts) console.log(` ${filePath}`)
|
|
151
|
+
} else {
|
|
152
|
+
console.log('\nno merge conflicts')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
`\nmerged: files=${Object.keys(merged).length} functionsExecuted=${countFunctionsTotal(merged)} -> ${path.join(OUT_DIR, 'coverage-final.json')}`,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const anyFailed = results.some((r) => !r.passed)
|
|
160
|
+
process.exitCode = anyFailed ? 1 : 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (import.meta.url === `file://${process.argv[1]}`) main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Deterministic static dashboard generator — no network. Boots the pipeline/collection registries
|
|
2
|
+
// the same way test/architecture/pipeline-invariants.test.ts does (no Nuxt boot needed for that), then
|
|
3
|
+
// renders through the SAME renderDashboard() the dev route uses, so the two surfaces never drift.
|
|
4
|
+
//
|
|
5
|
+
// Run with: npx tsx scripts/dashboard.mjs (or `pnpm dashboard`)
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { join, dirname } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import Database from 'better-sqlite3'
|
|
11
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
12
|
+
import { fieldTypes } from '@michaelthielemann/kestrel-fields'
|
|
13
|
+
import {
|
|
14
|
+
buildIntrospectionPipelines, buildPipelineIndex, clearPipelines, clearRegistry,
|
|
15
|
+
registerCollection, registerPipeline, renderDashboard, setResolvedKestrelConfig, allCollections,
|
|
16
|
+
} from '@michaelthielemann/kestrel-core'
|
|
17
|
+
import { buildAuthPipelines } from '@michaelthielemann/kestrel-auth'
|
|
18
|
+
import { pagesCollection } from '@michaelthielemann/kestrel-collections'
|
|
19
|
+
import { mediaCollection, mediaSettingsCollection } from '@michaelthielemann/kestrel-media'
|
|
20
|
+
import { redirectsCollection, siteCollection } from '@michaelthielemann/kestrel-publishing'
|
|
21
|
+
import { resolveServerKestrelConfig } from '../layers/core/server/utils/server-config.ts'
|
|
22
|
+
import { PLUGIN_ORDER } from '../layers/core/modules/plugin-order/plugin-order.ts'
|
|
23
|
+
import posts from '../server/collections/posts.ts'
|
|
24
|
+
import settings from '../server/collections/settings.ts'
|
|
25
|
+
|
|
26
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
+
|
|
28
|
+
void fieldTypes
|
|
29
|
+
setResolvedKestrelConfig(resolveServerKestrelConfig())
|
|
30
|
+
Object.assign(globalThis, {
|
|
31
|
+
useRuntimeConfig: () => ({
|
|
32
|
+
kestrel: { output: { driver: 'local', dir: '', publicDir: '/kestrel-no-such-public-dir', auto: true, publishOnSave: false, reconcileMinutes: 0, verbose: false, s3: {} } },
|
|
33
|
+
}),
|
|
34
|
+
useDb: () => drizzle(new Database(':memory:')),
|
|
35
|
+
primaryLocale: () => 'en',
|
|
36
|
+
prefixPrimaryLocale: () => false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
clearRegistry()
|
|
40
|
+
for (const c of [pagesCollection, posts, settings, mediaCollection, mediaSettingsCollection, redirectsCollection, siteCollection]) registerCollection(c)
|
|
41
|
+
|
|
42
|
+
clearPipelines()
|
|
43
|
+
for (const def of buildAuthPipelines()) registerPipeline(def)
|
|
44
|
+
for (const def of buildIntrospectionPipelines()) registerPipeline(def)
|
|
45
|
+
|
|
46
|
+
const collections = allCollections().map((c) => ({
|
|
47
|
+
name: c.name,
|
|
48
|
+
mode: c.def.mode,
|
|
49
|
+
translatable: Boolean(c.def.translatable),
|
|
50
|
+
fieldCount: Object.keys(c.def.fields).length,
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
/** Reads a repo-only enrichment file, or returns undefined — a consumer project (or a fresh checkout
|
|
54
|
+
* before `graphify update`/`pnpm api:check` have run) ships none of these, so every read is optional. */
|
|
55
|
+
function readJsonIfExists(relPath) {
|
|
56
|
+
const abs = join(repoRoot, relPath)
|
|
57
|
+
if (!existsSync(abs)) return undefined
|
|
58
|
+
return JSON.parse(readFileSync(abs, 'utf8'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function apiSurfaceSection() {
|
|
62
|
+
const ceilings = readJsonIfExists('test/architecture/public-api-ceilings.json')
|
|
63
|
+
if (!ceilings) return undefined
|
|
64
|
+
const out = []
|
|
65
|
+
for (const [report, ceiling] of Object.entries(ceilings)) {
|
|
66
|
+
const abs = join(repoRoot, report)
|
|
67
|
+
if (!existsSync(abs)) continue
|
|
68
|
+
const count = readFileSync(abs, 'utf8').split('\n').filter((l) => /^\/\/ @(public|alpha|beta|internal)\b/.test(l)).length
|
|
69
|
+
out.push({ report, count, ceiling })
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const edgeAllowlist = readJsonIfExists('test/architecture/edge-allowlist.json')
|
|
75
|
+
const apiSurface = apiSurfaceSection()
|
|
76
|
+
|
|
77
|
+
const html = renderDashboard({
|
|
78
|
+
pipelines: buildPipelineIndex(),
|
|
79
|
+
pluginOrder: PLUGIN_ORDER.map((p) => ({ layer: p.layer, file: p.file, after: p.after })),
|
|
80
|
+
collections,
|
|
81
|
+
graph: (edgeAllowlist || apiSurface) ? { packageEdges: edgeAllowlist?.edges, apiSurface } : undefined,
|
|
82
|
+
generatedAt: new Date().toISOString(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const outDir = join(repoRoot, 'docs')
|
|
86
|
+
mkdirSync(outDir, { recursive: true })
|
|
87
|
+
const outFile = join(outDir, 'dashboard.html')
|
|
88
|
+
writeFileSync(outFile, html)
|
|
89
|
+
console.log(`[dashboard] wrote ${outFile} (${buildPipelineIndex().length} pipelines, ${collections.length} collections)`)
|
|
@@ -3,7 +3,7 @@ import { hashPassword } from './lib/password.mjs'
|
|
|
3
3
|
import { Cancelled, promptPassword } from './lib/cli.mjs'
|
|
4
4
|
|
|
5
5
|
// Not used at runtime: this prints a value an operator pastes into KESTREL_ADMIN_PASSWORD_HASH. Kept as a
|
|
6
|
-
// standalone entry point because docs/
|
|
6
|
+
// standalone entry point because docs/guide/getting-started.md tells consumers to run it straight out of
|
|
7
7
|
// node_modules, which must keep working whether or not the `kestrel` bin is on PATH.
|
|
8
8
|
const arg = process.argv[2]
|
|
9
9
|
if (arg) {
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
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()
|
package/scripts/typecheck.mjs
CHANGED
|
@@ -44,6 +44,17 @@ pass('server (Nitro) project', 'tsc', ['--noEmit', '-p', gateConfig])
|
|
|
44
44
|
// 3. App / `.vue` / modules / config — vue-tsc via `nuxt typecheck` (needs the committed root tsconfig.json).
|
|
45
45
|
pass('app + .vue (vue-tsc)', 'nuxt', ['typecheck'])
|
|
46
46
|
|
|
47
|
+
// 4. Standalone workspace packages have their own tsconfig, outside the playground project. Globbed by
|
|
48
|
+
// pnpm's own filter (topological order, every current and future `@michaelthielemann/kestrel-*` package, `create-kestrel`
|
|
49
|
+
// excluded by name) rather than a hand-written list per package — the class of drift a hand list invites
|
|
50
|
+
// (a package silently missing from this one script) is the exact failure mode this rail exists to avoid.
|
|
51
|
+
console.log('› typechecking packages (@michaelthielemann/kestrel-*)…')
|
|
52
|
+
try {
|
|
53
|
+
execFileSync('pnpm', ['--filter', '@michaelthielemann/kestrel-*', '-r', 'typecheck'], { cwd: root, stdio: 'inherit', env })
|
|
54
|
+
} catch {
|
|
55
|
+
failed.push('packages (@michaelthielemann/kestrel-*)')
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
if (failed.length) {
|
|
48
59
|
console.error(`\n✗ typecheck failed: ${failed.join(', ')}`)
|
|
49
60
|
process.exit(1)
|